ipopt_outer_bound: a certified outer-bound spoke for convex NLP subproblems - #838
Open
DLWoodruff wants to merge 14 commits into
Open
ipopt_outer_bound: a certified outer-bound spoke for convex NLP subproblems#838DLWoodruff wants to merge 14 commits into
DLWoodruff wants to merge 14 commits into
Conversation
Design for a cylinder that supplies a *certified* outer bound for stochastic
programs whose scenario subproblems are convex NLPs solved with Ipopt. Today
there is none: Ipopt is not a branch-and-bound solver and reports no dual bound,
so Ebound() sums -inf and the Lagrangian spoke prints a warning saying as much.
The mechanism is not "solve the dual and read the value off". A second NLP solve
returns a point, and the value there is an upper bound on the dual function --
the wrong direction, so it certifies nothing. What closes it is Lagrangian weak
duality plus a tangent-plane underestimator minimized in closed form over the
variable box. That holds for ANY multipliers and needs neither convergence nor
feasibility of the point, so a truncated solve yields a valid-but-loose bound
rather than a wrong one. Because the ordinary subproblem solve already lands at
the minimizer, the correction term vanishes at an exact KKT point and the
cylinder costs one solve per scenario, not two.
Phase 1 lands the engine only -- no cylinder, no MPI, no config surface:
mpisppy/utils/dual_certificate.py
check_model_is_certifiable, unbounded_variables, certified_lower_bound.
Named neutrally because only the sign table is Ipopt-specific; the
convention is a sign_convention= argument rather than a hard-coded
assumption.
mpisppy/tests/test_dual_certificate.py
31 tests. Deliberately solver-free: the certificate is a pure function of
the point the variables hold and the values in the dual suffix, so both are
set by hand and every expected value is exact analytic arithmetic. The
assertions that genuinely need the real solver -- that Ipopt's reported
dual signs are what the table assumes -- are isolated in one skipUnless
class.
The Ipopt sign conventions were re-measured against analytically known
multipliers rather than carried over: an earlier draft of the table recorded the
equality dual as +4.0 where Ipopt reports -4.0, which contradicted its own
mu = -d rule. Both range-active-at-upper and range-active-at-lower are now
covered.
CI had no Ipopt anywhere, which would have let the Phase 2 and Phase 3
integration tests silently skip rather than fail, so the new ipopt-tests job
lands here rather than later. It pulls the IDAES idaes-ext release bundle --
the same source Pyomo's and Egret's own CI use -- because pip/conda Ipopt is
built against MUMPS only, while that bundle carries the HSL linear solvers. The
job asserts ma27/ma57/ma97 actually solve before running any test, and installs
libgfortran/liblapack/libblas first, since the binary carries no RPATH and
resolves them from the system.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…yomo#839 The last open question in the design was whether the existing stale-outer_bound fallback admits the mixed-W unsoundness in practice. It does: solve_one kept a subproblem's previous bound when a solve produced none, letting Ebound form a mixed-generation sum that is not a bound, past a missing-bound check that only looks for None. Reproduced and fixed in PR Pyomo#839, based on main rather than stacked here since it is a soundness bug in shipped code. Nothing in this design depends on the outcome -- ipopt reports Lower_bound = -inf rather than None and never reaches that path. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Phases 2 and 3 of the design: the spoke itself, wired end to end, so the
certificate engine from the previous commit has a consumer.
IpoptOuterBound(_LagrangianMixin, OuterBoundWSpoke) solves each subproblem with
the hub's current W, then replaces the solver's bound -- which for Ipopt is -inf
-- with the certificate computed from that subproblem's own duals. Unlike the
Lagrangian spoke it cannot use the bound-only solve path, since the certificate
needs the primals as well as the duals. A scenario with no certificate gets
None rather than -inf, so Ebound declines collectively instead of poisoning the
sum.
It does not reuse _LagrangianMixin.lagrangian(), which prints "An ipopt solver
will not give outer bounds". That warning is right about the Lagrangian spoke
and wrong here.
Guards, at setup and hard: a solver that is not Ipopt, plus the model checks the
engine already makes. Two that belong to the spoke rather than the engine:
- the proximal term must be absent, since a proximal subproblem is not the
Lagrangian relaxation;
- nonanticipative variables must not be fixed after setup. Fixing restricts
the subproblem, which can only raise its minimum, so the result would bound
the restricted problem and not the original. The engine cannot see this --
a fixed variable is simply a constant to it -- and PH's fixing extensions
are commonly on, so the spoke snapshots nonant fixedness at setup and
checks it each iteration.
Variables still unbounded after fbbt produce a rank-0 warning rather than an
error: this spoke is an optional source of a bound, and a model that is merely
under-bounded is not a broken model. It reports nothing on the iterations where
that costs it a bound.
Option routing follows the design's §7.1: this spoke does NOT inherit the global
--solver-options layer, because Ipopt hard-fails on an unrecognized keyword
rather than ignoring it, so an ordinary run (a MIP solver and its options for the
hub, this spoke alongside) would kill it on the first solve with an error naming
Ipopt rather than the option routing. Ipopt settings arrive through
--ipopt-outer-bound-solver-options. Its solver name defaults to ipopt so the
guard does not fire on the spoke's own default. No mipgap flags: Ipopt is not a
branch-and-bound solver and offering them would imply otherwise.
Verified end to end on farmer with 3 scenarios, PH hub on gurobi and this spoke
on Ipopt: the spoke supplies the best outer bound, -110093.79 against an EF
optimum of -108390.0.
Tests split by what they need. The wiring tests need neither solver nor MPI --
option routing is the part most likely to break silently, and it is checkable by
inspecting the spoke dict, including the assertion that lagrangian_spoke still
does inherit the global layer, which is what makes "this one does not" mean
something. The end-to-end test runs the hub on Ipopt too so it needs no MIP
solver, with a second case putting a MIP solver on the hub to demonstrate the
routing, skipped when none is available.
The design's file table said the driver wiring lives in generic_cylinders.py.
It does not: spoke construction is in generic/spokes.py and arg registration in
generic/parsing.py. Table corrected.
No run_all.py entry. do_one has no skip machinery, so an entry would force an
Ipopt install into both run_all CI jobs, which have no other use for one. The
same coverage -- the documented command line still works -- is bought far more
cheaply by a driver smoke run inside the ipopt-tests job, which already has
Ipopt.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
build_spoke_list reads cfg.ipopt_outer_bound directly, as it does for every other spoke, so a Config that has not declared the flag raises AttributeError rather than skipping the spoke. test_flexible_rank_cli builds a deliberately explicit Config -- its comment says it declares "the subset that build_spoke_list reads ... so every cfg flag it touches exists" -- so adding a spoke means adding its args there. The test was doing its job; it caught this in CI. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Codecov Report❌ Patch coverage is
Additional details and impacted files@@ Coverage Diff @@
## main #838 +/- ##
==========================================
+ Coverage 77.77% 77.85% +0.08%
==========================================
Files 177 179 +2
Lines 23764 23982 +218
==========================================
+ Hits 18482 18672 +190
- Misses 5282 5310 +28 ☔ View full report in Codecov by Harness. 🚀 New features to boost your workflow:
|
…edge HSL Two additions, both prompted by reading the CI log for the ipopt-tests job. The smoke run reported a final bound of -109499.5162. The value test_with_cylinders asserts for the ORDINARY Lagrangian spoke on the same problem is -109499.5160897. farmer is an LP, so an LP solver's dual bound is the Lagrangian dual value exactly -- and this spoke arrives at the same number by an entirely different route, a tangent plane over the variable box built from Ipopt's duals. That agreement is much sharper evidence than "the bound does not exceed the optimum", which a badly broken certificate could still pass by being very negative, so it is now an explicit test rather than a coincidence in a log. Both legs run the same hub with the same rho and iteration count, so the hub walks the same W trajectory and the two spokes are asked about the same relaxations; spoke bounds do not feed back into W. Measured agreement is ~1.2e-4, asserted at 1e-2, and separately asserted in the safe direction: the certificate carries the box correction and the cushion, so it may be looser than the exact LP dual bound but must never be more optimistic. The Lagrangian leg needs a solver that actually reports a dual bound; it prefers whatever get_solver finds and falls back to cbc, which ships in the same idaes-ext bundle that supplies Ipopt, so this test runs rather than skips in CI. Second: the idaes-ext Ipopt links HSL and defaults to the ma27 linear solver rather than to MUMPS, so these tests have been solving with HSL all along. Ipopt's own banner says any publicity material resulting from use of the HSL codes must carry an acknowledgement, and our solves run with tee=False, so that banner never reaches the screen. mpisppy/tests/utils.py grows a helper that probes which linear solver ipopt actually uses -- one trivial solve into a logfile, cached -- and prints the acknowledgement once per process when HSL is in use. The ipopt-dependent test modules call it, the CI job echoes the same text, and spokes.rst notes it. The probe is exception-safe and silent when ipopt is absent or is not an HSL build: a courtesy message must never break a test run. Which linear solver is in use is worth surfacing anyway, since results can differ between ma27 and MUMPS and nothing else in the output says which ran. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The Lagrangian-agreement test failed in CI:
AssertionError: np.float64(-109499.51616667982) != np.float64(nan)
The nan is the Lagrangian spoke's bound. On the runner cbc solves to optimality
and still leaves Problem[0].Lower_bound empty --
[LagrangianOuterBound] No outer bound for scenario AboveAverageScenario0
status= ok
TerminationCondition= optimal
-- so the spoke never sends a bound and spcomm.bound stays at its initial nan.
The comparison then had nothing to compare against.
The test picked cbc on the reasoning that it ships in the same idaes-ext bundle
as Ipopt, so CI would have it. Available is not the same as useful, and neither
is solving to optimality: what this test needs is a solver that fills in a dual
bound, and that is worth checking rather than assuming. _reports_dual_bound now
solves a trivial LP with the candidate and looks. It is checked locally too --
cbc does report a bound here, which is exactly why the original reasoning
survived local testing and only broke on the runner.
Also widened the candidates. glpk is added ahead of cbc and installed in the
job (a small apt package; it is not needed by Ipopt, it is there to give the
certificate a second opinion). glpk cannot handle a PH proximal term, but the
Lagrangian spoke runs with attach_prox=False, so that limitation does not apply.
Measured: lagrangian(glpk) = -109499.51604354104 against gurobi's
-109499.51604354102, so it is a faithful reference.
The persistent interfaces need set_instance before a solve, which the probe does
not do, so the plain solver name is now tried alongside the persistent one.
Finally, a nan bound is diagnosed rather than asserted against: if the
comparison solver turns out to report nothing, that is a broken premise for the
test rather than a failure of the spoke under test, so it skips with a message
saying so. This test can no longer fail for want of a reference solver -- worst
case it skips and says why.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…findings
A high-effort review of this branch found a soundness hole and a run-killer.
Both reproduce; the rest are correctness and convention fixes.
**The canonical form negates the body of a `>=` row, and nothing said so.**
Constraints are rewritten as g(v) <= 0, so `body >= lower` becomes
g = lower - body: the theorem needs the BODY concave there, not convex. The
guard rejected nonlinear equalities and let everything else through, and the
unit test and spokes.rst both codified "only equalities have to be affine".
Consequence, reproduced:
min x s.t. x**2 >= 1, x in [0, 1.5] true optimum 1.0
check_model_is_certifiable: ACCEPTED
certified_lower_bound: 1.25 an outer bound ABOVE the optimum
Convexity of a one-sided nonlinear body is not decidable here and stays the
user's assertion -- but the direction of that assertion is now stated, in the
module docstring, in spokes.rst as a table, and in the design's section 3.1.
What IS decidable is now enforced: a two-sided (ranged) row splits into both
g = body - upper and g = lower - body, so its body must be affine, and a
nonlinear one is rejected exactly as a nonlinear equality already was. The test
that asserted "any nonlinear inequality is allowed" is replaced by four that
pin down the real rule, including that the guard does not claim to catch the
`>=` case.
**--max-solver-threads defeated the no-global-options decision.** The factory
cleared the global layers, and then apply_solver_specs re-applied the thread cap
afterwards. translate_solver_options has no ipopt entry for `threads`, so it
reached the solver verbatim and killed the spoke's first solve -- the exact leak
section 7.1 exists to prevent, reintroduced by a later step. Stripped after the
call, with a regression test; the existing test only covered --solver-options.
(Note `--solver-name ipopt --max-solver-threads N` still fails for the HUB. That
is pre-existing -- it reproduces on clean main with an unrelated spoke -- and is
not touched here.)
Also:
- The proximal-term guard was dead code: prox_disabled reads prox_on, which
starts at 0, so `not prox_disabled` was always False, and the second conjunct
(`and not W_disabled`) inverted the intent. Now a live check.
- CertificateError could escape the iteration loop and MPI_Abort the hub and
every other spoke over a routine solver outcome (a constraint the solve left
without a dual). An optional bound source must not do that: it now warns once
on rank 0 and reports no bound, which is what the module's contract already
promised. Same for nonants fixed after setup, which now stands the spoke down
rather than killing the run.
- converger_spoke_char was 'I', which is InnerBoundSpoke's character; the hub
prints the outer and inner chars side by side, so an 'I' in the outer column
read as an inner bound. Now 'N', for NLP.
- The dual-suffix check tested only for existence, so a scenario_creator that
attaches an EXPORT `dual` suffix (a normal way to supply warm starts) would
silently import nothing. Now checks import_enabled().
- generic/admm.py's _count_cylinders spoke_flags list did not include the new
spoke, so --admm --ipopt-outer-bound configured the wrapper for a smaller
wheel than WheelSpinner builds.
- test_flexible_rank_cli enabled every spoke but this one, so its rank_ratio
wiring was untested in the file whose whole purpose is that wiring.
- announce_hsl_if_used printed on every rank. The project convention is rank-0
only, and I had a note to that effect and wrote it anyway.
Not addressed here: the duplication between this spoke's main() and
LagrangianOuterBound.main() is real, but lifting the loop into _LagrangianMixin
touches shared PH machinery and belongs in its own change.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
DLWoodruff
marked this pull request as ready for review
August 18, 2026 16:53
…erview Two wording fixes to the ipopt_outer_bound section of spokes.rst. The opening paragraph carried its subject entirely in pronouns -- "It does not simply report...", "What it computes instead...", "rather than a wrong one" -- which forces the reader to hold the referent across four sentences of fairly dense argument. Each now names what it is talking about. And "so it remains your assertion" becomes "so convexity remains a user assertion": the sentence is about what the guard does and does not check, so saying which property is left to the user is the useful half. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"Inside the theorem" was doing real work -- the hypotheses hold, so the
guarantee applies -- but it treats a theorem as a container, which invites the
reader to picture something that is not there. Five sites, two phrasings:
Prose says what actually happens. "x**2 <= 4 is inside the theorem and
x**2 >= 1 is not" becomes "the theorem applies to x**2 <= 4 but not to
x**2 >= 1", and the guard rationale in the design now says the model "genuinely
violates an assumption".
The two docstrings in dual_certificate.py name the cause instead of the theory,
since they are read at the moment someone hits the error: CertificateError is
raised when "the model violates an assumption the certified bound depends on".
That also corrects a smaller slip -- the class previously said "the theorem this
module certifies", when what the module certifies is a bound.
Left alone: ordinary noun uses of "theorem" ("valid by a theorem", "the theorem
in section 3.3", "what the theorem wants"), which read fine.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
The markdown design covers decisions and implementation, and states the mathematics in the register of a design note: enough to follow the argument, not enough to check it. This adds the derivation properly, with the hypotheses named and the proofs written out. Contents: the subproblem and why the solver's returned objective value is an inner bound; weak duality for arbitrary multipliers (Lemma 1) and the infimum trap that stops it from being usable directly; the tangent-plane underestimator minimised in closed form over the box (Theorem 2), whose corollary is that the bound holds for any iterate and any multipliers, so no claim about the accuracy of the solve is needed anywhere; the vanishing of the correction term at a KKT point (Proposition 4), which is why the certificate costs nothing when the solve is good and loosens smoothly when it is not; the canonical-form table and the sign condition, with the worked counterexample where a convex body on a `>=` row certifies 1.25 for a problem whose optimum is 1; and the aggregation proposition, including why weights from mixed iterations break it. Closes with the tightness estimate (the shortfall is the gradient magnitude times the distance to the far end of the box, which is why bounds tightening is the main lever on quality) and a table mapping each object to the function that implements it. Five pages, compiles clean with pdflatex and nothing beyond amsmath/amsthm/ booktabs/hyperref. Following the convention already used for doc/slides, the .tex and the built .pdf are both committed and the rest of pdflatex's output is ignored -- the existing ignore block only covered doc/slides, so it now covers doc/designs as well. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…bare
Theorem 2 is the Frank-Wolfe duality gap. For convex f on a compact convex D,
evaluating the linear minimisation oracle at x gives
f(x) + min_{y in D} grad f(x)^T (y - x) <= min_D f
which is the theorem with D the variable box, where the oracle is separable and
closed-form. That bound is as old as the Frank-Wolfe algorithm (1956); reading
it as a certificate computable at any iterate, rather than as a step-size
device, is Jaggi's framing (2013). Presenting it as a numbered theorem with a
proof and no attribution implied a novelty that is not there.
A new section now says which wheel each result re-uses: Lemma 1 is textbook
weak duality; Theorem 2 is the Frank-Wolfe gap; Proposition 5 is the standard
progressive hedging dual-bound argument (Gade et al. 2016); and combining
progressive hedging with Frank-Wolfe machinery for Lagrangian dual bounds is
itself established (Boland et al. 2018) -- mpi-sppy ships an FWPH cylinder on
that basis, which makes the omission the more glaring. Both of those papers
were already in doc/src/refs.rst.
What is specific here is an assembly, not a result, and the section says so: the
multipliers are the ones Ipopt already returns, the linearisation point is the
iterate it already stopped at, and the box is the model's own variable bounds,
so the bound comes out of a solve the spoke was going to perform anyway. The
abstract now leads with "none of the mathematics is new" and points at the
section, so a reader is oriented before the proofs rather than after.
The wider inexact-oracle bundle literature is mentioned as the place this
question is treated properly, without pretending to survey it.
Adds a four-item bibliography; still no dependency beyond amsmath/amsthm/
booktabs/hyperref, and no bibtex pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"it is worth saying which wheel each result re-uses" leans on an idiom, and "wheel" is a loaded word in this codebase besides. Replaced with a plain statement of what the section does. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
…it is "as old as the Frank-Wolfe algorithm itself" and "its modern reading" both attribute by date, which says nothing a reader can use. What matters is that the bound is used in the Frank-Wolfe algorithm, and that the same oracle evaluation which produces it there also supplies the search direction -- so the difference from this note is what the quantity is used FOR, not when anyone first wrote it down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
"useless", "lever" and "hygiene" were doing informal work in a document that
otherwise holds a formal register:
- "Why the returned objective value is useless here" -> "is not a bound here",
which also states the actual reason rather than a verdict;
- "the single most effective lever on quality" -> "the most effective way to
improve the bound";
- "it is hygiene, not part of the argument" -> "it is a numerical safeguard,
not part of the argument".
"The trap" and "the whole point" are kept. Both are direct rather than
colloquial, and each marks a turning point in the argument where a reader
benefits from being told to slow down.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
What this is
A new spoke,
ipopt_outer_bound, that supplies a certified outer bound forstochastic programs whose scenario subproblems are convex NLPs solved with Ipopt.
Today there is none. Ipopt is not a branch-and-bound solver and reports no dual
bound, so
Ebound()sums-inf, andlagrangian_bounder.pysays so out loud:Design document, engine, cylinder, config surface, docs and tests — the whole
feature. Still a draft because the design is the part that most wants
argument, and it should be read before the code.
The idea, and the trap it avoids
The obvious approach — solve the subproblem, take Ipopt's multipliers, solve the
Lagrangian dual, report the value — does not close. A second NLP solve
returns a point, and the value there is an upper bound on the dual function.
Wrong direction; it certifies nothing.
What does close it is Lagrangian weak duality plus a tangent-plane
underestimator minimized in closed form over the variable box:
This holds for any
lam >= 0and anymu, needing neither convergencenor feasibility of
vhat. Three consequences:one. A bad sign convention or a stale dual can only cost tightness.
objective value and strong duality is recovered exactly.
second solve per iteration is nearly a no-op. One solve per scenario per
iteration, same as the Lagrangian spoke.
Convexity is the load-bearing assumption. What can be checked mechanically is
checked and is a hard error; the rest is documented as the user's assertion.
It works
farmer, 3 scenarios, PH hub on gurobi, this spoke on Ipopt:
Valid outer bound, 1.6% off after 5 iterations, with the hub and the spoke
running different solvers on their own copies of the models.
Layout
doc/designs/ipopt_outer_bound_design.mdmpisppy/utils/dual_certificate.pydualsuffix. No MPI, no cylinder, no PH.mpisppy/cylinders/ipopt_outer_bound.pympisppy/utils/config.py,cfg_vanilla.py,generic/spokes.py,generic/parsing.pympisppy/tests/test_dual_certificate.py,test_ipopt_outer_bound.pydoc/src/spokes.rst.github/workflows/test_pr_and_main.ymlipopt-testsjob.The engine is named neutrally and takes
sign_convention="ipopt"as an argument,since only the sign table is Ipopt-specific. The spoke keeps the Ipopt name,
because the scope decision is real.
Guards
Hard errors at setup: discrete variables, nonlinear equality constraints, a
maximization objective, a solver that is not Ipopt, and an attached proximal
term (a proximal subproblem is not the Lagrangian relaxation).
One more that the engine cannot see, so it lives in the spoke: nonanticipative
variables fixed after setup. Fixing restricts the subproblem, which can only
raise its minimum, so the number would bound the restricted problem and not the
original. A fixed variable is simply a constant to the engine, and PH's fixing
extensions are commonly on — so the spoke snapshots nonant fixedness at setup and
checks it each iteration.
Variables still unbounded after
fbbtwarn rather than raise. This spoke isan optional source of a bound; a model that is merely under-bounded is not a
broken model, and it reports nothing on the iterations where that costs it.
Option routing
This spoke does not inherit the global
--solver-optionslayer — the onlyone that does not. Ipopt hard-fails on an unrecognized keyword rather than
ignoring it, so an entirely ordinary run (a MIP solver and its options for the
hub, this spoke attached alongside) would kill it on the first solve, with an
error naming Ipopt rather than the option routing. Ipopt settings arrive through
--ipopt-outer-bound-solver-options.Filtering the global dict against a list of Ipopt-known keywords was considered
and rejected: the list would have to track Ipopt releases, and silently dropping
an option the user set is worse than never applying it.
Tests, and why they are shaped this way
Most need neither a solver nor MPI. The certificate is a pure function of the
point the variables hold and the values in the
dualsuffix, so both are set byhand and every expected number is exact analytic arithmetic —
q = 8exactly atthe KKT point, not a tolerance. Option routing is likewise checkable by
inspecting the spoke dict.
That is not stylistic. Until this PR there was no Ipopt anywhere in
.github/workflows/, so a solver-gated suite would have skipped in CI andreported nothing.
Beyond the happy path they pin the properties the argument rests on: validity
from non-optimal and infeasible points; a wrong sign giving a loose-but-valid
bound rather than a wrong one; looseness scaling with box width;
Noneratherthan
-infon an unbounded direction; that the global solver-options layer doesnot reach this spoke while
lagrangian_spokestill inherits it, which is whatmakes that assertion mean anything.
Pairwise monotonicity across
max_iteris deliberately not asserted: theiterate path is a solver detail that can differ by linear-solver build, and a test
that passes on MUMPS but fails on HSL is worse than no test.
Ipopt in CI
New
ipopt-testsjob, pulling the release bundle fromIDAES/idaes-ext— thesame source Pyomo's and Egret's own CI use — because pip/conda Ipopt is built
against MUMPS only, while that bundle carries the HSL linear solvers.
Two details handled explicitly: the job asserts
ma27/ma57/ma97actuallysolve before running any test (a silent MUMPS-only fallback would otherwise
surface much later as an abnormal exit), and it
apt-get installslibgfortran/liblapack/libblasfirst, since the binary carries no RPATH.Pyomo's workflow does the same for the same reason.
There is deliberately no
run_all.pyentry:do_onehas no skip machinery,so an entry would force an Ipopt install into both
run_alljobs, which have noother use for one. The same coverage is bought by a driver smoke run inside
ipopt-tests, which already has Ipopt.Correctness note
The Ipopt dual sign conventions were re-measured against analytically known
multipliers rather than carried forward. An earlier draft of the table recorded
the equality dual as
+4.0where Ipopt reports-4.0, contradicting its ownmu = -drule. Corrected, with both range-active-at-upper andrange-active-at-lower now covered.
Scope
The design lists five phases; this PR is phases 1–3, which is the complete
feature: engine, cylinder, wiring, docs, tests. Deferred and possibly
permanently, better filed as issues than kept as planned phases:
~0 at a converged solve, so it may never be worth building;
someone needs it.
A separate soundness bug found while writing §8 is #839, based on
mainrather than stacked here. Ipopt never reaches that path, so nothing here depends
on it.
Worth a reviewer's attention
setup-time enough in practice?