Skip to content

Add OA equality relaxation penalties#20

Merged
bernalde merged 3 commits into
mainfrom
fix/issue-12-equality-penalty
Jun 25, 2026
Merged

Add OA equality relaxation penalties#20
bernalde merged 3 commits into
mainfrom
fix/issue-12-equality-penalty

Conversation

@bernalde

Copy link
Copy Markdown
Member

Summary

  • Carry NLP multipliers through OA subproblem solves so equality-relaxation cuts can be oriented from the dual sign.
  • Add bounded nonnegative slack variables for selected OA/ECP master cuts and penalize them in the master objective.
  • Treat slack-assisted and heuristic_nonconvex=True OA as incumbent-only: no public bound/gap certificate is reported.
  • Forward the issue-12 MIP-NLP options through solver="mip-nlp" and reject unrelated future options.

Closes #12.

Tests

  • PYTHONPATH=python pytest python/tests/test_mip_nlp.py -q
    • 9 passed
  • PYTHONPATH=python pytest python/tests/test_mip_nlp.py python/tests/test_oa.py -q
    • 16 passed, 10 deselected
  • PYTHONPATH=python pytest python/tests/test_mip_nlp.py python/tests/test_oa.py python/tests/test_gdp.py -q
    • 118 passed, 22 deselected
  • PYTHONPATH=python pytest python/tests/test_lp_backend_select.py python/tests/test_adversarial_recent_fixes.py -q
    • 14 passed, 10 deselected
  • make lint RUFF='python -m ruff'
    • passed
  • PYTHONPATH=python python -m mypy python/discopt/ --ignore-missing-imports
    • passed

Notes:

  • Local git commit hook could not run because pre-commit is not installed in this environment. The equivalent Ruff and mypy checks above were run directly.
  • Local tests emitted JAX persistent-cache write warnings because the cache directory is read-only in this environment.

Branch Hygiene

@bernalde bernalde marked this pull request as ready for review June 24, 2026 21:00

@bernalde bernalde left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maintainer review of PR #20 (OA equality relaxation + augmented penalty, Closes #12). I am the PR author, so GitHub only allows a COMMENT review here; the formal approval or change request must come from another maintainer.

Overall this is solid and the design is sound. The default (non-slack) OA path is preserved bit-for-bit: public_bound_valid collapses to decomp.master_bound_valid when add_slack=False, the linear-objective c vector and epigraph index reduce to the prior values when there are no slacks, and every append to oa_A_rows now routes through _append_master_cut, keeping oa_slack_flags aligned (the _solve_master_milp length check guards against drift). Slack rows are constructed correctly (coeffs.x - s <= rhs, s in [0, max_slack], penalized in the objective), and slacks are correctly excluded from objective-epigraph, no-good, and feasibility cuts. gap_certified is a real SolveResult field and NLPResult.multipliers exists, so the new wiring does not raise. The _add_oa_cuts rewrite from generate_oa_cuts_from_evaluator to a manual generate_oa_cut loop faithfully reproduces generate_oa_cuts_from_evaluator_report (same cons_vals/jac/convex-mask-skip), with the added benefit of a row index for dual orientation. The four issue-12 acceptance items (cut orientation, slack row construction, penalty coefficients, gap_certified=False) each have a corresponding test.

No blocking issues.

Nonblocking

  1. The MIP-NLP option-key list is now duplicated in three places: python/discopt/solver.py:2376, python/discopt/solver.py:2690, and _OA_OPTION_KEYS in python/discopt/solvers/mip_nlp.py:15. This PR had to extend all three in lockstep; a future option added to only one will silently fail to forward (solver.py) or be wrongly rejected (mip_nlp.py). Define the set once (e.g. export _OA_OPTION_KEYS from mip_nlp.py) and have both solve_model extraction blocks iterate it.

  2. Minor: unsupported options raise ValueError when entered through solve_mip_nlp (mip_nlp.py:56) but NotImplementedError when entered through solve_oa's new kwargs guard (oa.py). Same error class, two exception types depending on entry point. Consider aligning them.

Questions

  1. Dual-orientation multiplier sign convention (oa.py:345-346). _equality_relaxation_sigma assumes a fixed sign convention for multipliers[row_index], but NLPResult documents multipliers as being "in the sign convention of the problem as passed to the solver," and OA can use either nlp_pounce or nlp_ipopt. If those two backends differ in sign convention, the chosen cut orientation will be correct for one and inverted for the other. Because slack/heuristic runs are uncertified this is not a soundness bug, but an inverted orientation defeats the feature's purpose. Confirm both backends share the assumed convention, or normalize per-solver, and consider an integration test that exercises multipliers end-to-end (the current test calls _equality_relaxation_sigma directly).

  2. Certification scope of equality_relaxation alone (oa.py:848). Uncertification is tied to add_slack (and heuristic_nonconvex, which forces add_slack). A caller passing equality_relaxation=True without add_slack still gets gap_certified=True and a public bound/gap, even though relaxing a nonlinear equality to a single-direction tangent cut is a nonconvex heuristic. This matches pre-PR behavior (no regression), but issue #12's task "mark heuristic nonconvex OA results as uncertified" is arguably broader than the slack path. Confirm whether standalone equality_relaxation should also set gap_certified=False, or document that it is intended only for affine/convex-compatible equalities.

Issue intent: Closes #12 is appropriate. #12 is a sub-issue of epic #7 and its four tasks and four acceptance-test items are all delivered. Question 2 above is the only arguable gap, and it is a scoping clarification rather than a missed core ask.

Tests run (clean worktree at head 247f2b7, with the prebuilt _rust extension copied in since it is not part of the diff):

  • PYTHONPATH=python pytest python/tests/test_mip_nlp.py -q -> 9 passed.
  • PYTHONPATH=python pytest python/tests/test_mip_nlp.py python/tests/test_oa.py -q -> 16 passed, 10 deselected.
  • PYTHONPATH=python pytest python/tests/test_mip_nlp.py test_oa.py test_gdp.py test_lp_backend_select.py test_adversarial_recent_fixes.py -q -> 132 passed, 32 deselected.
  • ruff check and ruff format --check on the four changed files -> passed.
    The only initial failures were ModuleNotFoundError: discopt._rust, a local build artifact (the Rust extension is not in the diff); they cleared once the prebuilt .so was made importable.

Merge-readiness: ready on the merits. No blocking issues; the nonblocking maintainability item and the two questions can be addressed in follow-up. The formal approval must come from another maintainer since I am the author.

Comment thread python/discopt/solver.py Outdated
mip_nlp_options = kwargs.pop("mip_nlp_options", None)
mip_nlp_kwargs: dict[str, Any] = {}
for key in ("equality_relaxation", "ecp_mode", "feasibility_cuts"):
for key in (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nonblocking (maintainability). This option-key tuple is duplicated at python/discopt/solver.py:2690 and again as _OA_OPTION_KEYS in python/discopt/solvers/mip_nlp.py:15. The issue-12 options had to be added to all three; a future option added to only one will silently fail to forward here or be wrongly rejected in mip_nlp. Define the set once (export _OA_OPTION_KEYS from mip_nlp.py) and iterate it in both extraction blocks.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 72b7c5e. OA_OPTION_KEYS is now defined once in discopt.solvers.mip_nlp and both solve_model extraction blocks iterate that shared set.

if multipliers is None or row_index >= len(multipliers):
return 1.0
dual = float(multipliers[row_index])
sign_adjust = 1.0 if objective_sense == ObjectiveSense.MAXIMIZE else -1.0

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question. This assumes a fixed sign convention for multipliers[row_index], but NLPResult documents multipliers as being in "the sign convention of the problem as passed to the solver," and OA can run on either nlp_pounce or nlp_ipopt. If those backends differ in convention, the orientation is correct for one and inverted for the other. Not a soundness bug (slack/heuristic runs are uncertified), but an inverted orientation defeats the feature. Confirm both backends agree, or normalize per-solver, and consider an end-to-end test (the current test calls _equality_relaxation_sigma directly).

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 72b7c5e. Both NLP wrappers expose Ipopt-compatible mult_g unchanged; the OA helper now documents that assumption, and test_equality_relaxation_orientation_flips_master_cut_row verifies the sign flips the actual appended master cut row.

Comment thread python/discopt/solvers/oa.py Outdated
model._objective.sense if model._objective is not None else ObjectiveSense.MINIMIZE
)
constraint_cut_mask = None if heuristic_nonconvex else decomp.oa_constraint_mask
public_bound_valid = decomp.master_bound_valid and not add_slack

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Question. Uncertification is tied to add_slack only. A caller setting equality_relaxation=True without add_slack still gets gap_certified=True plus a public bound/gap, despite single-direction tangent relaxation of a nonlinear equality being a nonconvex heuristic. This matches pre-PR behavior (no regression), but issue #12's "mark heuristic nonconvex OA results as uncertified" may be broader than the slack path. Should standalone equality_relaxation also set gap_certified=False, or is it intended only for affine/convex-compatible equalities?

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in 72b7c5e. Integer OA runs that actually relax equality cuts now suppress public bound/gap reporting and return gap_certified=False; test_er_helps_nonlinear_equality asserts that behavior.

@bernalde

Copy link
Copy Markdown
Member Author

Pushed commit 72b7c5e (Address OA review feedback).

Main changes:

  • Replaced the duplicated OA option-key tuples with OA_OPTION_KEYS exported from discopt.solvers.mip_nlp and reused by both solve_model routing paths.
  • Aligned direct solve_oa(..., unknown_option=...) handling with the facade by raising ValueError.
  • Documented that OA uses Ipopt-compatible mult_g signs from both NLP backends, and added a cut-generation test that verifies equality-relaxation orientation flips the actual master row.
  • Treat integer OA runs that relax equality cuts as uncertified: no public bound/gap and gap_certified=False.

Tests run:

  • PYTHONPATH=python pytest python/tests/test_mip_nlp.py python/tests/test_oa.py -q -> 18 passed, 10 deselected.
  • PYTHONPATH=python pytest python/tests/test_mip_nlp.py python/tests/test_oa.py python/tests/test_gdp.py python/tests/test_lp_backend_select.py python/tests/test_adversarial_recent_fixes.py -q -> 134 passed, 32 deselected.
  • make lint RUFF="python -m ruff" -> passed.
  • PYTHONPATH=python python -m mypy python/discopt/ --ignore-missing-imports -> passed with existing unchecked-body notes only.

Comments intentionally not addressed:

  • None.

Remaining risks or follow-up:

  • The local commit hook could not run because pre-commit is not installed in this environment; the equivalent Ruff and mypy checks were run directly.

@bernalde bernalde left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Focused review of commit 72b7c5ed2 ("Address OA review feedback"), scoped to the new certification-gating logic in solve_oa and the multiplier sign-convention claim, per the round-2 verification hand-off. I am the PR author, so GitHub only permits a COMMENT review; the formal approval or change request must come from another maintainer, and the round-1 threads remain unresolved.

Verification result: the new gating is sound and the sign-convention claim holds. No blocking issues.

What I checked and confirmed:

  • No over-certification path. uses_uncertified_relaxation = add_slack or uses_relaxed_equality_cuts gates every relevant exit: the LB update (oa.py:1030), the final bound (oa.py:1172, so reported_gap collapses to None), and gap_certified on all four return SolveResult sites. When tainted, bound/gap are None and gap_certified is False; status is never "optimal".
  • The predicate is correctly scoped. uses_relaxed_equality_cuts requires bool(decomp.int_indices), which matches where relaxed equality cuts are actually emitted: _add_oa_cuts only runs in the integer loop (the no-integer branch returns after a direct, exact NLP solve, so leaving it certified is right). A == in decomp.constraint_senses[:n_cons] is a nonlinear equality (affine equalities live in the linear set), so it is genuinely nonconvex and tainting is justified.
  • The sign-convention claim is verified, not just asserted. Both backends store info["mult_g"] into NLPResult.multipliers unchanged (nlp_ipopt.py:227,241; nlp_pounce.py:113,127, which is documented as cyipopt-semantics-compatible), so the two agree on convention and the orientation logic is fed consistent duals. That resolves the round-1 Question.
  • Tests pass at 72b7c5ed2: pytest python/tests/test_mip_nlp.py test_oa.py test_gdp.py test_lp_backend_select.py test_adversarial_recent_fixes.py -q -> 134 passed, 32 deselected (the two new tests included).

Nonblocking

  1. Docstring not updated for the changed contract. The equality_relaxation docstring (oa.py, around line 802) still describes only the mechanism. Enabling it now suppresses the certified bound/gap (bound/gap None, gap_certified=False, status never "optimal") whenever a nonlinear equality is relaxed. This is a public option whose result contract changed; document it as the add_slack/heuristic_nonconvex entries already do.

  2. Missing test for the certified-default boundary. The new tests cover the tainting case (test_oa.py::TestEqualityRelaxation) and orientation, but nothing asserts the predicate does NOT over-trigger: e.g. equality_relaxation=True on a convex MINLP with no nonlinear equality should still return gap_certified=True. Add that case to lock in that the new taint is scoped to relaxed equalities and does not regress certified convex OA.

  3. Orientation may use unconverged duals. _solve_nlp returns multipliers even on ITERATION_LIMIT (its own comment notes the IPM "may not certify dual convergence"), and _equality_relaxation_sigma orients cuts from them. Soundness is unaffected (these runs are uncertified), but cut orientation can silently degrade. Consider only trusting multipliers from an OPTIMAL NLP exit, or note the limitation in the helper.

Question

  1. In test_oa.py::TestEqualityRelaxation, the in-test comment says the equality is "relaxed to x^2 <= y" and the assertion is status in ("optimal", "feasible"). With the new dual-oriented, always-uncertified path the status can only be "feasible" or "iteration_limit", never "optimal", and the orientation is multiplier-driven rather than fixed to <=. Worth tightening the comment/assertion to document the actual contract.

Merge-readiness: from this focused review, the new gating logic is correct and merge-ready; the items above are follow-ups, not blockers. Because this is my own PR, the formal verdict must come from another maintainer, and the round-1 review threads are still open. I would not consider the verification loop closed until those threads are resolved, but I see nothing here that blocks merge.

model._objective.sense if model._objective is not None else ObjectiveSense.MINIMIZE
)
constraint_cut_mask = None if heuristic_nonconvex else decomp.oa_constraint_mask
uses_relaxed_equality_cuts = (

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nonblocking (doc gap). This predicate changes the public result contract for equality_relaxation: when it is True and a nonlinear equality is relaxed (integers present), bound/gap become None, gap_certified is False, and status is never "optimal". The equality_relaxation docstring (around line 802) still describes only the mechanism. Add a sentence noting the uncertified result, mirroring the add_slack/heuristic_nonconvex docstring entries. The predicate logic itself is sound: integer-gating matches where _add_oa_cuts actually relaxes equalities, and a == in constraint_senses[:n_cons] is a nonlinear (nonconvex) equality.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f9e4771. The equality_relaxation docstring now states that runs relaxing nonlinear equality cuts do not report certified public bounds or gaps.

"""Return the dual-oriented sign for a relaxed equality row."""
if multipliers is None or row_index >= len(multipliers):
return 1.0
# Both NLP backends expose Ipopt-compatible ``mult_g`` values unchanged;

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nonblocking. The claim is verified: both nlp_ipopt and nlp_pounce store info["mult_g"] unchanged (nlp_pounce is documented cyipopt-compatible), so the two backends agree and orientation is consistent. One residual: _solve_nlp returns multipliers even on ITERATION_LIMIT, where its own comment notes dual convergence is not certified. _equality_relaxation_sigma then orients cuts from possibly-unconverged duals. Soundness is fine (these runs are uncertified), but orientation can silently degrade; consider trusting multipliers only from an OPTIMAL exit, or note the limitation here.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f9e4771. _solve_nlp now drops multipliers on ITERATION_LIMIT and test_iteration_limited_nlp_drops_uncertified_multipliers covers that behavior.

Comment thread python/tests/test_oa.py
assert result.status in ("optimal", "feasible")
assert result.bound is None
assert result.gap is None
assert result.gap_certified is False

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nonblocking (missing test for changed-behavior boundary). This locks in the tainting case well. Add the complementary case: equality_relaxation=True on a convex MINLP with no nonlinear equality should still return gap_certified=True (the predicate's any(sense == "==") is False), to prove the new taint does not over-trigger and regress certified convex OA. Separately (Question): the in-test comment "relaxed to x^2 <= y" and status in ("optimal", "feasible") no longer match the dual-oriented, always-uncertified behavior (status can only be "feasible"/"iteration_limit" now); consider tightening.

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed in f9e4771. Added test_er_without_nonlinear_equality_remains_certified, and tightened the nonlinear-equality ER test comment plus status assertion to the incumbent-only uncertified contract.

@bernalde

Copy link
Copy Markdown
Member Author

Pushed commit f9e4771 (Refine OA equality relaxation certification).

Main changes:

  • Updated the equality_relaxation docstring to state that runs relaxing nonlinear equality cuts do not report certified public bounds or gaps.
  • Stopped carrying NLP multipliers from ITERATION_LIMIT results into OA equality-relaxation orientation; those primal-feasible points are still usable for linearization, but their uncertified duals are dropped.
  • Added test_iteration_limited_nlp_drops_uncertified_multipliers.
  • Added the complementary certified-boundary test: equality_relaxation=True remains certified when no nonlinear equality cut is relaxed.
  • Tightened the nonlinear-equality ER test comment and status assertion to match the incumbent-only uncertified contract.

Tests run:

  • PYTHONPATH=python pytest python/tests/test_mip_nlp.py python/tests/test_oa.py -q -> 20 passed, 10 deselected.
  • PYTHONPATH=python pytest python/tests/test_mip_nlp.py python/tests/test_oa.py python/tests/test_gdp.py python/tests/test_lp_backend_select.py python/tests/test_adversarial_recent_fixes.py -q -> 136 passed, 32 deselected.
  • make lint RUFF="python -m ruff" -> passed.
  • PYTHONPATH=python python -m mypy python/discopt/ --ignore-missing-imports -> passed with existing unchecked-body notes only.

Comments intentionally not addressed:

  • None from the current actionable review set. Older out-of-date threads already had replies from 72b7c5e, so I did not duplicate those.

Remaining risks or follow-up:

  • The local commit hook could not run because pre-commit is not installed in this environment; the equivalent Ruff and mypy checks were run directly.

@bernalde bernalde merged commit def2aa0 into main Jun 25, 2026
6 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

[MIP-NLP] Equality relaxation and augmented penalty

1 participant