Skip to content

ipopt_outer_bound: a certified outer-bound spoke for convex NLP subproblems - #838

Open
DLWoodruff wants to merge 14 commits into
Pyomo:mainfrom
DLWoodruff:ipopt-outer-bound
Open

ipopt_outer_bound: a certified outer-bound spoke for convex NLP subproblems#838
DLWoodruff wants to merge 14 commits into
Pyomo:mainfrom
DLWoodruff:ipopt-outer-bound

Conversation

@DLWoodruff

@DLWoodruff DLWoodruff commented Aug 18, 2026

Copy link
Copy Markdown
Collaborator

What this is

A new spoke, ipopt_outer_bound, 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 lagrangian_bounder.py says so out loud:

if "ipopt" in self.opt.options["solver_name"]:
    print("\n WARNING: An ipopt solver will not give outer bounds\n")

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:

q(lam, mu) >= phi(vhat) + sum_i min_{v_i in [lo_i, hi_i]} d_i phi(vhat) * (v_i - vhat_i)

This holds for any lam >= 0 and any mu, needing neither convergence
nor feasibility of vhat. Three consequences:

  • A truncated or sloppy solve gives a valid but loose bound, never a wrong
    one. A bad sign convention or a stale dual can only cost tightness.
  • The correction vanishes at an exact KKT point, so the bound collapses to the
    objective value and strong duality is recovered exactly.
  • Because the ordinary solve already lands at that minimizer, the anticipated
    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:

Iter.           Best Bound  Best Incumbent
    4  I      -114092.2008             inf
    5  I      -110093.7884             inf     <- EF optimum is -108390.0

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

File
doc/designs/ipopt_outer_bound_design.md The design, §1–12.
mpisppy/utils/dual_certificate.py The engine. Pure function of a solved model + its dual suffix. No MPI, no cylinder, no PH.
mpisppy/cylinders/ipopt_outer_bound.py The spoke.
mpisppy/utils/config.py, cfg_vanilla.py, generic/spokes.py, generic/parsing.py Config surface and wiring.
mpisppy/tests/test_dual_certificate.py, test_ipopt_outer_bound.py Tests.
doc/src/spokes.rst User-facing page.
.github/workflows/test_pr_and_main.yml New ipopt-tests job.

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 fbbt warn rather than raise. This spoke is
an 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-options layer — the only
one 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 dual suffix, so both are set by
hand and every expected number is exact analytic arithmetic — q = 8 exactly at
the 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 and
reported 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; None rather
than -inf on an unbounded direction; that the global solver-options layer does
not reach this spoke while lagrangian_spoke still inherits it, which is what
makes that assertion mean anything.

Pairwise monotonicity across max_iter is deliberately not asserted: the
iterate 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-tests job, pulling the release bundle from IDAES/idaes-ext — 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.

Two details handled explicitly: the job asserts ma27/ma57/ma97 actually
solve before running any test (a silent MUMPS-only fallback would otherwise
surface much later as an abnormal exit), and it apt-get installs
libgfortran/liblapack/libblas first, since the binary carries no RPATH.
Pyomo's workflow does the same for the same reason.

There is deliberately no run_all.py entry: do_one has no skip machinery,
so an entry would force an Ipopt install into both run_all jobs, which have no
other 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.0 where Ipopt reports -4.0, contradicting its own
mu = -d rule. Corrected, with both range-active-at-upper and
range-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:

  • an opt-in tightening re-solve — by the design's own argument the correction is
    ~0 at a converged solve, so it may never be worth building;
  • maximization, a hard error today, which is a fine permanent state unless
    someone needs it.

A separate soundness bug found while writing §8 is #839, based on main
rather than stacked here. Ipopt never reaches that path, so nothing here depends
on it.

Worth a reviewer's attention

  • §3.3, the underestimator argument — everything rests on it.
  • §7.1, the decision not to inherit global solver options.
  • The fixed-nonant guard: is checking every iteration the right call, or is
    setup-time enough in practice?

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>
DLWoodruff and others added 2 commits August 17, 2026 17:15
…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>
@DLWoodruff DLWoodruff changed the title ipopt_outer_bound: design and Phase 1 certificate engine ipopt_outer_bound: a certified outer-bound spoke for convex NLP subproblems Aug 18, 2026
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

codecov Bot commented Aug 18, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 88.07339% with 26 lines in your changes missing coverage. Please review.
✅ Project coverage is 77.85%. Comparing base (29671ec) to head (7752b32).

Files with missing lines Patch % Lines
mpisppy/cylinders/ipopt_outer_bound.py 75.23% 26 Missing ⚠️
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.
📢 Have feedback on the report? Share it here.

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.

DLWoodruff and others added 3 commits August 17, 2026 18:17
…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 and others added 7 commits August 18, 2026 10:03
…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>
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.

1 participant