Skip to content

Optional xhatter feasibility cuts, V1 (binary first-stage) — addresses #601#671

Open
DLWoodruff wants to merge 20 commits into
Pyomo:mainfrom
DLWoodruff:xhat_feasibility_cuts_design
Open

Optional xhatter feasibility cuts, V1 (binary first-stage) — addresses #601#671
DLWoodruff wants to merge 20 commits into
Pyomo:mainfrom
DLWoodruff:xhat_feasibility_cuts_design

Conversation

@DLWoodruff

@DLWoodruff DLWoodruff commented Apr 24, 2026

Copy link
Copy Markdown
Collaborator

Design document: doc/xhat_feasibility_cuts_design.md — please read that first. It lays out the motivation, the V1/V2/V3 scope table, the code-sharing opportunities with cross_scen, and the open decisions (Q1–Q4) the current implementation encodes.

Summary

First-milestone implementation of issue #601: xhat spokes (xhatlooper, xhatshufflelooper, xhatspecific, xhatxbar) may now emit a no-good feasibility cut when their candidate is infeasible in some scenario. The hub installs the cut into every scenario (persistent-solver aware, bundle-safe), so the same xhat is not revisited.

  • Off by default. Enabled with --xhat-feasibility-cuts-count N (N > 0). N doubles as the per-iteration cap and sizes the shared-memory send buffer.
  • Binary first-stage only. The hub extension hard-fails at setup time if any nonant is not binary; integer vars with bounds [0, 1] are accepted as semantically binary.
  • Two-stage only in V1. The hub extension hard-fails at setup time if the model is multi-stage: the no-good row is applied positionally against nonant_indices, which is only consistent across scenarios when they share the same ROOT nonants (two-stage NAC). Multi-stage needs a branch-aware installer and is deferred to a follow-up milestone (see the design doc).

What this PR adds

  • Field.XHAT_FEASIBILITY_CUT on the shared-memory window (row format [constant, nonant_coefs…] + trailing count slot).
  • XhatFeasibilityCutExtension (new hub extension) — binary-only startup check, per-scenario xhat_feasibility_cuts ConstraintList, persistent-solver-aware installation.
  • Emission path in XhatBase._try_one: no-good row with constant = #{xhat=1} − 1, coef_i = 1 − 2·xhat_i, so constant + Σ coef_i · x_i ≥ 0 is violated exactly at xhat.
  • cfg_vanilla plumbing (shared_options carries the cap; add_xhat_feasibility_cuts(hub_dict, cfg) attaches the extension when the flag is positive).
  • Wired through the generic_cylinders driver (mpisppy/generic/parsing.py, mpisppy/generic/extensions.py) and examples/usar/wheel_spinner.py.
  • New user-facing doc/src/xhat_feasibility_cuts.rst wired into index.rst, plus cross-references from spokes.rst and extensions.rst.
  • New part-1 examples/run_all.py entry re-running the USAR wheel spinner with --xhat-feasibility-cuts-count=3 to guard the full-run plumbing from regressions.

Scope boundaries

  • V1 (this PR): binary first-stage, any recourse (MIP OK). Covers UC / scheduling / lot-sizing.
  • V2 (follow-up): Farkas feasibility cuts via a Pyomo upstream extension to BendersCutGeneratorData (currently only emits optimality cuts). Lifts the first-stage-type restriction but requires LP recourse.
  • V3 (not in this design): integer L-shaped for MIP recourse.

See the scope table in the design doc.

Follow-ups

  • #670 — cut-pool management. Today cuts accumulate indefinitely (same behavior as CrossScenarioExtension for benders_cuts). The per-iter cap keeps growth linear in iterations; Cut-pool management for cross-scenario and xhat feasibility cuts #670 will add actual pruning, shared between both features.
  • Once this PR and #672 (--xhat-from-file) both merge, a small follow-up can add a run_all.py entry that combines them: write a known-infeasible binary xhat to a .npy, run --xhat-from-file <path> --xhat-feasibility-cuts-count=1, and verify the full emission → install loop end-to-end. That closes the "Known gap" below.

Test plan

  • ruff check mpisppy/ — clean.
  • python -m unittest mpisppy.tests.test_xhat_feasibility_cuts — 16 new tests, all pass. Covers startup check (binary passes; 0..1-bounded integer passes; continuous / integer-not-01 fail with expected error text), cut installation (basic, zero-count no-op, all-zero-row skipped, accumulation), no-good encoding (including the "cut violates at its own xhat" sanity check), and multi-node startup scan.
  • python -m unittest mpisppy.tests.test_ef_ph mpisppy.tests.test_ph_extensions mpisppy.tests.test_proper_bundler — 38 pre-existing xhat / PH / bundler tests still pass.
  • cd doc && make html — no new warnings.
  • Local USAR smoke: mpirun -np 3 python -m mpi4py wheel_spinner.py ... --xhat-feasibility-cuts-count=3 completes cleanly; baseline USAR run without the flag is unaffected.
  • Full CI.

Known gap

No end-to-end run with a crafted infeasible xhat. The new USAR run_all.py entry drives the full WheelSpinner with --xhat-feasibility-cuts-count on a binary first-stage model — it guards setup-time plumbing (field registration, binary-only startup check, extension wiring) — but that instance does not actually force an infeasibility, so the emission → install path is covered only by the unit tests in test_xhat_feasibility_cuts.py. Happy to add a forced-infeasibility test if reviewers want it.

🤖 Generated with Claude Code

DLWoodruff and others added 5 commits April 23, 2026 16:46
Design doc addresses issue Pyomo#601: when an xhatter (xhatlooper,
xhatshufflelooper, xhatspecific) detects infeasibility in a
scenario, optionally generate a feasibility cut and push it into
every scenario so the same xhat is not revisited.

Captures the blueprint (mirror cross_scen_cuts on the shared-memory
window, spoke-side generation, hub-side extension), the pyomo
Benders gap (current generate_cut hard-errors on non-optimal
subproblems, so reusing lshaped_cuts is necessary but not sufficient
for the feasibility-cut case), and the three code-sharing
opportunities with cross_scen.

First-milestone scope is 0/1-only no-good cuts, which work for both
two-stage and multi-stage. Cut-pool management is split to its own
tracking issue (Pyomo#670) covering cross_scen too.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
- Replace "0/1 (or integer)" language with explicit "binary"
  throughout. The textbook no-good cut is only valid for binary
  first-stage variables; a mix of binary and continuous nonants
  cannot be handled by cutting the binary part alone, because the
  continuous part can be perturbed.
- Add a "Startup check" subsection: XhatFeasibilityCutExtension
  .setup_hub scans every nonant across every node (so multi-stage
  is covered) and raises RuntimeError when any non-binary nonant
  is present. Fail fast, rather than silently producing an invalid
  relaxation at first infeasibility.
- Drop the per-iteration "check if binary, otherwise skip" from the
  spoke-side generation path; the precondition is now a setup-time
  invariant.
- Add a negative regression test (4d) to the first-milestone scope:
  enabling the feature on a model with a continuous nonant must
  raise the expected RuntimeError at setup.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Expand first-milestone item 5: name the user-facing rst file
(doc/src/xhat_feasibility_cuts.rst), list the required contents
(scope, CLI flag, binary-only error text, bundle note, pointer to
Pyomo#670), and note the index.rst + xhatter-cross-reference edits.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add an explicit scope table distinguishing V1 (no-good, binary
first-stage only, any recourse), V2 (Farkas via a pyomo-Benders
extension, any first-stage type but LP recourse only), and V3
(integer L-shaped, not in scope of this design but listed so the
boundary is clear).

The Farkas cut is linear in the first-stage variables regardless of
their type; the real V2 restriction is on the second stage, not the
first.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Design: doc/xhat_feasibility_cuts_design.md. Addresses issue Pyomo#601.

New pieces:
- Field.XHAT_FEASIBILITY_CUT on the shared-memory window, with a
  cuts-per-iter buffer size driven by cfg.xhat_feasibility_cuts_count.
- Config arg --xhat-feasibility-cuts-count (default 0 = off; positive
  integer caps cuts per iteration and sizes the send buffer).
- XhatFeasibilityCutExtension (hub-side):
  * setup_hub hard-fails at startup if any first-stage nonant is not
    binary, so the feature never silently emits an invalid relaxation
    (integer 0..1-bounded vars are accepted as binary);
  * receives cuts from Field.XHAT_FEASIBILITY_CUT and installs each
    cut into every local scenario's xhat_feasibility_cuts
    ConstraintList, persistent-solver aware, bundle-safe.
- Xhat spokes (via XhatInnerBoundBase) declare the send field; the
  shared XhatBase._try_one emits a no-good row on infeasibility via
  _maybe_emit_feasibility_cut, gated on the cfg flag. No-good encoding:
  constant = (#{xhat=1}) - 1; coef_i = 1 - 2*xhat_i; constraint is
  constant + sum coef_i * x_i >= 0.
- cfg_vanilla: shared_options propagates the cap to every spoke and
  the hub; new add_xhat_feasibility_cuts(hub_dict, cfg) attaches the
  hub extension when the flag is positive.

Docs:
- New user-facing doc/src/xhat_feasibility_cuts.rst covering what the
  feature does, the CLI flag, the binary-only precondition with the
  exact RuntimeError text, proper-bundle interaction, and the
  forward pointer to Pyomo#670 for cut-pool management.
- Hooked into index.rst; cross-references added from spokes.rst and
  extensions.rst.

Tests: 16 new tests in mpisppy/tests/test_xhat_feasibility_cuts.py
covering the startup check (binary passes, continuous/integer fails,
0..1-bounded integer passes, cuts_count=0 construction fails), cut
installation (basic, zero-count no-op, all-zero-row skipped,
accumulation), no-good-cut encoding (including the "cut violates at
its own xhat" sanity check), and multi-node startup scan. Full
test_ef_ph + test_ph_extensions + test_proper_bundler suites (54
tests) still pass with no regressions.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
@codecov

codecov Bot commented Apr 24, 2026

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 93.28859% with 10 lines in your changes missing coverage. Please review.
✅ Project coverage is 76.30%. Comparing base (64a8fff) to head (94b37ea).

Files with missing lines Patch % Lines
mpisppy/cylinders/xhatbase.py 60.00% 10 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##             main     #671      +/-   ##
==========================================
+ Coverage   76.16%   76.30%   +0.13%     
==========================================
  Files         169      170       +1     
  Lines       22224    22372     +148     
==========================================
+ Hits        16928    17070     +142     
- Misses       5296     5302       +6     

☔ 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.

- mpisppy/generic/parsing.py: register xhat_feasibility_cut_args so
  the flag is available to the generic_cylinders driver.
- mpisppy/generic/extensions.py: call vanilla.add_xhat_feasibility_cuts
  when cfg.xhat_feasibility_cuts_count > 0.
- examples/usar/wheel_spinner.py: register the arg and wire the hub
  extension. USAR has a binary first-stage (is_active_depot) so it
  exercises the startup check end-to-end.
- examples/run_all.py: add a part-1 smoke entry that re-runs the USAR
  wheel_spinner with --xhat-feasibility-cuts-count=3, guarding the
  full WheelSpinner plumbing from regressions. The USAR instance is
  not crafted to be infeasible, so this guards setup-time behavior
  (buffer + field registration, binary-only startup check, extension
  wiring); the emission -> install path remains covered by the unit
  tests in test_xhat_feasibility_cuts.py.
- doc/src/xhat_feasibility_cuts.rst, extension error message: drop
  "local" from user-facing references to "local scenario" per user
  feedback (it leaks a developer-facing attribute name into user
  text; rank-locality isn't a distinction the user can act on).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DLWoodruff added a commit to DLWoodruff/mpi-sppy-1 that referenced this pull request Apr 24, 2026
Design: doc/xhat_from_file_design.md.

Every xhat spoke (xhatlooper, xhatshufflelooper, xhatspecific,
xhatxbar) that descends from XhatInnerBoundBase now optionally
reads a .npy file holding a first-stage xhat vector, evaluates it
across all scenarios once via Xhat_Eval.evaluate, and reports the
resulting inner bound before its normal exploration loop starts.

- cfg.xhat_from_file_args() registers --xhat-from-file (str, default
  None = off).
- XhatInnerBoundBase._try_file_xhat() loads via
  ciutils.read_xhat, validates (length vs root-node nonant count,
  two-stage only, file exists), calls self.opt.evaluate, calls
  self.update_if_improving if Eobj is finite, and restores nonants
  either way. Hard-fails on any precondition violation rather than
  silently skipping.
- xhat_prep() calls the helper after _save_nonants, so every xhat
  spoke picks it up for free.
- shared_options carries the path into every xhat spoke's options.
- generic_cylinders wiring via mpisppy/generic/parsing.py.

V1 is two-stage only, matching ciutils.read_xhat; multi-stage raises
with a clear error and is listed as a follow-up.

Tests: 11 unit tests in mpisppy/tests/test_xhat_from_file.py covering
off-by-default, missing file, multi-stage reject, length mismatch,
happy path with finite Eobj, infeasible-style (inf / None) Eobj, and
sanity checks on the math.isfinite predicate. Existing xhat / PH
tests (34 tests) still pass.

User-facing doc/src/xhat_from_file.rst: covers the three use cases
(warm start from prior run on a similar instance, user heuristic,
testing infeasibility cuts), the flag, the format, precedence with
Jensen's --*-try-jensens-first, and the interaction with
--xhat-feasibility-cuts-count (PR Pyomo#671). Wired into index.rst.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Add focused unit tests that bring patch-line coverage on the PR to
100%:

- Extension plumbing: register_send_fields (noop), register_receive
  _fields with 0/1/>1 emitter ranks, sync_with_spokes with/without a
  buffer and with an is_new-gated buffer, and the persistent-solver
  branch of _install_cuts (via a real PersistentSolver subclass).
- _maybe_emit_feasibility_cut early-return branches: spcomm=None,
  send field not registered, buffer-size mismatch (RuntimeError),
  and a nonant whose value is None.
- _try_one exception wrapper: stub self.opt minimally and monkey-
  patch _maybe_emit_feasibility_cut to raise; the wrapper must log
  and swallow.
- Plumbing: Config.xhat_feasibility_cut_args, cfg_vanilla.shared_
  options propagation, cfg_vanilla.add_xhat_feasibility_cuts on/off
  branches, FieldLengths setter that reads the cap from options,
  and the generic/extensions.configure_extensions gate.
- Direct drive of generic.parsing.parse_args with a stub module so
  the cfg.xhat_feasibility_cut_args() call inside parse_args is
  exercised.
- Import mpisppy.cylinders.xhatbase explicitly so its class-level
  send_fields registration is counted as exercised.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DLWoodruff added a commit that referenced this pull request Apr 26, 2026
* Add design doc for supplying an initial xhat from a file

Introduces --xhat-from-file, a single-flag feature on the xhat
spokes that reads a .npy first-stage vector (via the existing
ciutils.read_xhat helper), evaluates it across scenarios via
Xhat_Eval.evaluate, and reports the resulting inner bound as the
spoke's first candidate — before the normal xhatter exploration
loop.

Two uses motivate it:
1. User-supplied warm start / heuristic candidate.
2. End-to-end testing of xhat feasibility cuts (PR #671): feed a
   known-infeasible binary xhat from a file, assert the cut lands
   and the xhat is not revisited.

Design document covers the precedence rule with Jensen's
--*-try-jensens-first (file beats Jensen's beats normal),
multi-stage deferral (V1 is two-stage only, matching
ciutils.read_xhat), the hook point (right after xhat_prep,
mirrors Jensen's), CLI/cfg plumbing, and the first-milestone
scope.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Implement --xhat-from-file V1

Design: doc/xhat_from_file_design.md.

Every xhat spoke (xhatlooper, xhatshufflelooper, xhatspecific,
xhatxbar) that descends from XhatInnerBoundBase now optionally
reads a .npy file holding a first-stage xhat vector, evaluates it
across all scenarios once via Xhat_Eval.evaluate, and reports the
resulting inner bound before its normal exploration loop starts.

- cfg.xhat_from_file_args() registers --xhat-from-file (str, default
  None = off).
- XhatInnerBoundBase._try_file_xhat() loads via
  ciutils.read_xhat, validates (length vs root-node nonant count,
  two-stage only, file exists), calls self.opt.evaluate, calls
  self.update_if_improving if Eobj is finite, and restores nonants
  either way. Hard-fails on any precondition violation rather than
  silently skipping.
- xhat_prep() calls the helper after _save_nonants, so every xhat
  spoke picks it up for free.
- shared_options carries the path into every xhat spoke's options.
- generic_cylinders wiring via mpisppy/generic/parsing.py.

V1 is two-stage only, matching ciutils.read_xhat; multi-stage raises
with a clear error and is listed as a follow-up.

Tests: 11 unit tests in mpisppy/tests/test_xhat_from_file.py covering
off-by-default, missing file, multi-stage reject, length mismatch,
happy path with finite Eobj, infeasible-style (inf / None) Eobj, and
sanity checks on the math.isfinite predicate. Existing xhat / PH
tests (34 tests) still pass.

User-facing doc/src/xhat_from_file.rst: covers the three use cases
(warm start from prior run on a similar instance, user heuristic,
testing infeasibility cuts), the flag, the format, precedence with
Jensen's --*-try-jensens-first, and the interaction with
--xhat-feasibility-cuts-count (PR #671). Wired into index.rst.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Raise patch coverage on PR #672

Add three focused tests that bring patch coverage on #672's diff to
full:

- TestConfigArgRegistration: direct call to Config.xhat_from_file_args
  plus a drive of generic.parsing.parse_args with a stub module so
  the cfg.xhat_from_file_args() call inside parse_args is exercised.
- TestXhatPrepCallSite: stub self.opt with the minimum that
  xhat_prep touches and verify xhat_prep reaches
  self._try_file_xhat() (gated off, so the real helper
  early-returns). This covers the call site inside xhat_prep.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* Run test_xhat_from_file under coverage in CI and run_coverage.bash

The 14 unit tests in mpisppy/tests/test_xhat_from_file.py exercise
every branch of XhatInnerBoundBase._try_file_xhat, but neither the
regression job in .github/workflows/test_pr_and_main.yml nor the
local run_coverage.bash invoked the file under coverage run. As a
result Codecov saw the helper as 36.67% covered on PR #672 — the
covered lines being only the early-return path reached incidentally
by other tests.

Add a coverage-instrumented pytest invocation of test_xhat_from_file.py
to both runners alongside the other serial pytest phases.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
DLWoodruff and others added 8 commits April 26, 2026 15:07
…ts_design

# Conflicts:
#	doc/src/index.rst
#	mpisppy/generic/parsing.py
#	mpisppy/utils/cfg_vanilla.py
#	mpisppy/utils/config.py
PR Pyomo#672's --xhat-from-file feeds a file-supplied xhat through
Xhat_Eval.evaluate before the xhatter's main loop. Until now, an
infeasible file-supplied xhat just produced an "Eobj=None; not
updating inner bound" log line and discarded the candidate. The
PR Pyomo#672 description called out this exact integration as a
motivating use case: feed a known-infeasible binary xhat to drive
PR Pyomo#671's feasibility-cut emission end-to-end.

Wire the two:

- Extract pack_no_good_feasibility_cut(opt) as a module-level
  helper in mpisppy/extensions/xhatbase.py (the existing
  XhatBase._maybe_emit_feasibility_cut becomes a thin wrapper, so
  the original call site in _try_one is unchanged).
- In XhatInnerBoundBase._try_file_xhat, after evaluate, check
  no_incumbent_prob() != 0 (same predicate _try_one uses) and call
  pack_no_good_feasibility_cut before _restore_nonants. evaluate
  and the prob-check are wrapped in try/except so a stub Xhat_Eval
  without those methods (or an evaluate that raises on infeasibility)
  doesn't break the helper.

Tests in mpisppy/tests/test_xhat_from_file.py
(TestFileXhatEmitsFeasibilityCut, three cases):

- Infeasible file xhat with cuts_count=1 emits exactly one cut on
  Field.XHAT_FEASIBILITY_CUT, with the correct no-good row
  ([constant, coefs...]) for the supplied xhat. Asserts the cut
  violates at its own xhat (sanity).
- Feature off (cuts_count=0): no put_send_buffer call.
- Feasible file xhat (Eobj finite, infeasP=0): no cut emitted,
  inner-bound update happens normally.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Two changes bundled here:

1. Restrict V1 to two-stage at hub setup time. The no-good cut row
   encodes coefficients positionally against each scenario's
   nonant_indices. In two-stage every scenario shares the same ROOT
   nonants under nonanticipativity, so the same row applied to every
   scenario is consistent. In multi-stage, scenarios on different
   branches have different per-stage-2+ variables at the deeper
   indices, so the same row applied positionally lands coefficients
   on unrelated variables — the resulting "cut" on a divergent
   scenario is meaningless. A multi-stage-correct installer must
   group cuts by branch (deferred to a follow-up alongside V2).

   XhatFeasibilityCutExtension._assert_two_stage raises at setup_hub
   when opt.multistage is true. The two-stage gate runs before the
   binary check, so a multi-stage all-binary model still hits the
   multi-stage error.

   The user-facing doc/src/xhat_feasibility_cuts.rst and the design
   doc/xhat_feasibility_cuts_design.md are updated to reflect this:
   the previous "no-good cuts work in multi-stage from day one" claim
   was overstated — the cut form is multi-stage-valid but the
   installer is not, and V1 hard-fails rather than silently produce
   invalid relaxations. The V1/V2/V3 scope table gains a "Multi-stage"
   row; first-milestone test list swaps the multi-stage positive test
   for a multi-stage rejection test.

2. Wire test_xhat_feasibility_cuts.py into both coverage runners
   (regression job in .github/workflows/test_pr_and_main.yml and the
   local run_coverage.bash). Same gap PR Pyomo#672 had pre-fix: the test
   file wasn't invoked under coverage run, so Codecov never saw the
   feature exercised.

Tests: TestMultiNodeStartupCheck → TestMultiStageRejection. New
cases: _assert_two_stage raises with the expected message; setup_hub
hits the multi-stage error before reaching the binary check. All 56
tests across test_xhat_feasibility_cuts.py and test_xhat_from_file.py
pass.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
Follow the doc/designs/ convention introduced in Pyomo#678. Updates the five
references (extension docstrings, the user-facing rst, and the test docstring).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
# Conflicts:
#	.github/workflows/test_pr_and_main.yml
#	mpisppy/cylinders/spwindow.py
#	mpisppy/utils/cfg_vanilla.py
#	run_coverage.bash
Resolved 5 conflicts, all where upstream work landed on the same lines
as the feasibility-cut feature:

- mpisppy/cylinders/spwindow.py: kept the Pyomo#727 fix
  (_total_number_scenarios = len(all_scenario_names)) and retained the
  xhat_feasibility_cuts_per_iter buffer-sizing line.
- mpisppy/cylinders/xhatbase.py: kept upstream's .csv/multistage
  xhat-from-file reader (Pyomo#760, uses n_nonants) and wrapped its evaluate
  call with the feasibility-cut emit-on-infeasibility path.
- mpisppy/extensions/xhatbase.py: XhatBase._try_one now runs BOTH
  upstream's write_iis_on_xhatter_infeasible (Pyomo#754) and the feature's
  _maybe_emit_feasibility_cut (independent, complementary).
- mpisppy/generic/parsing.py: took upstream's add_decomp_args refactor
  (single source of truth) and registered xhat_feasibility_cut_args()
  inside it, so both generic_cylinders and mrp_generic expose the flag.
- doc/src/index.rst: kept both new toctree entries
  (xhat_feasibility_cuts.rst and upstream's iis.rst).

Test reconciliation in test_xhat_feasibility_cuts.py:
- _StubOpt gained a no-op write_iis_on_xhatter_infeasible (matches the
  stubs upstream added in test_feasible_xhat.py / test_jensens.py).
- _make_configured_cfg now builds its cfg from add_decomp_args instead
  of a hand-maintained arg list, so it no longer breaks when upstream
  adds an extension flag (slamming, w_oscillation were the trigger).

test_xhat_feasibility_cuts, test_xhat_from_file, test_iis_on_infeasible,
test_feasible_xhat, and test_config all pass.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
The doc still read "Draft — nothing is implemented yet" (2026-04-23)
and described editing each xhatter spoke. Reconcile it with what
actually landed in PR Pyomo#671:

- Header now states V1 is implemented (PR Pyomo#671).
- New "Implementation status (V1 as shipped)" section listing the
  delivered pieces and the six ways the build diverged from the
  original proposal (centralized emit helper instead of per-spoke
  edits; xhat-from-file participation; consolidated-nonant bundle
  install; Constraint(Any) container; integer-[0,1] accepted as
  binary; log-and-swallow robustness).
- Corrected the now-contradicting prose in "Spoke-side generation",
  "Hub-side extension" (bundle bullet), and "Plumbing touchpoints"
  so the doc no longer describes work that was superseded.

Forward-looking sections (V2 Farkas, V3 integer L-shaped, multi-stage)
are unchanged — that work is still future.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
@DLWoodruff DLWoodruff marked this pull request as ready for review July 9, 2026 16:30
The merge kept a feasibility-cut buffer-sizing line that reads
opt.options.get(...), but FieldLengths otherwise only requires the
nonant/scenario attributes. The upstream cross-scenario buffer-sizing
test (Pyomo#727) builds a minimal SimpleNamespace opt with no .options,
so that line raised AttributeError and failed CI's Basic regression
job at test_cross_scenario_buffer_sizing.py.

Guard with getattr(opt, "options", {}) — a missing .options means the
feature is off (cap 0), which is the correct default. Restores the
minimal-opt contract FieldLengths honored before the merge.

Verified: the full Basic regression set (test_cross_scenario_buffer_sizing,
test_xhat_feasibility_cuts, test_generic_cylinders, test_w_oscillation,
et al.) passes locally.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

Pull request overview

Adds an optional “xhat feasibility cuts” mechanism so xhat spokes can emit a binary no-good cut on infeasible candidates, and the hub can install that cut across scenarios to prevent revisiting the same xhat.

Changes:

  • Introduces a new shared-memory field (Field.XHAT_FEASIBILITY_CUT) and spoke-side packing helper for no-good feasibility cuts.
  • Adds a new hub extension (XhatFeasibilityCutExtension) plus config/driver plumbing to enable the feature via --xhat-feasibility-cuts-count N.
  • Adds documentation, CI/coverage wiring, and unit tests (including integration with --xhat-from-file).

Reviewed changes

Copilot reviewed 19 out of 19 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
run_coverage.bash Adds coverage run for the new feasibility-cut test module.
mpisppy/utils/config.py Registers new CLI/config option xhat_feasibility_cuts_count.
mpisppy/utils/cfg_vanilla.py Propagates the cap via shared options and wires the new hub extension.
mpisppy/tests/test_xhat_from_file.py Extends file-xhat tests to cover infeasible-path cut emission.
mpisppy/tests/test_xhat_feasibility_cuts.py Adds comprehensive unit tests for packing, hub install, wiring, and guards.
mpisppy/generic/parsing.py Exposes the new CLI flag in generic driver parsing.
mpisppy/generic/extensions.py Attaches the hub extension when the flag is enabled.
mpisppy/extensions/xhatbase.py Adds pack_no_good_feasibility_cut and calls it from xhat infeasibility handling.
mpisppy/extensions/xhat_feasibility_cut_extension.py New hub-side extension receiving/installing feasibility cuts.
mpisppy/cylinders/xhatbase.py Advertises the new send field and emits a cut for infeasible --xhat-from-file candidates.
mpisppy/cylinders/spwindow.py Adds new Field and buffer sizing logic driven by the configured cap.
examples/usar/wheel_spinner.py Wires hub extension into the USAR wheel spinner driver.
examples/run_all.py Adds a smoke entry enabling the feature for USAR.
doc/src/xhat_feasibility_cuts.rst New user-facing documentation page for the feature.
doc/src/spokes.rst Adds cross-reference to the feasibility-cuts documentation.
doc/src/index.rst Adds the new doc page to the docs index.
doc/src/extensions.rst Documents the new flag under extensions.
doc/designs/xhat_feasibility_cuts_design.md Adds/updates the design document capturing V1 scope and future milestones.
.github/workflows/test_pr_and_main.yml Adds CI step running the new feasibility-cut unit tests.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment thread mpisppy/extensions/xhat_feasibility_cut_extension.py
Comment on lines +76 to +80
if getattr(self.opt, "multistage", False):
raise RuntimeError(
"--xhat-feasibility-cuts-count > 0 is two-stage only in "
"V1. Multi-stage support is planned as a follow-up "
"milestone (the install side needs to group cuts by "

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right — the code and design doc are the source of truth, and V1 is two-stage only (_assert_two_stage hard-fails on multi-stage). The PR description bullet was stale; I've corrected it to state two-stage only in V1, with the positional-nonant_indices rationale and multi-stage deferred to a follow-up milestone. No code change — the multi-stage guard already matches the design.

Comment thread mpisppy/cylinders/xhatbase.py
DLWoodruff and others added 2 commits July 9, 2026 10:45
…rface swallowed eval error

Copilot review of PR Pyomo#671:

- register_receive_fields no longer asserts a single emitting rank. Every
  xhat inner-bound spoke advertises Field.XHAT_FEASIBILITY_CUT, so a run with
  more than one xhat spoke (e.g. --xhatshuffle --xhatxbar) has several emitting
  ranks and the old `assert len(ranks) == 1` crashed. Register one recv buffer
  per rank and install cuts from whichever is new; a no-good cut from any
  xhatter is valid for every scenario.

- _try_file_xhat now prints the exception on rank 0 before degrading to
  Eobj=None, so a real evaluate/solver failure is not silently hidden.

Tests updated to the list-based recv buffers; adds coverage for multiple
emitters registering and installing from each buffer.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>

@bknueven bknueven left a comment

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

No-good cuts cost nothing, so this is a low-risk feature that could help certain classes of SMIPs. I say we ship it.

to converge.')
```

That is, it generates **optimality cuts only** from optimally-solved

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Claude is wrong about this, but I understand why it's confused. Pyomo's Benders machinery generated blended optimality and feasibility cuts in a single pass.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

You're right — thanks. I traced it through _setup_subproblem: it relaxes every subproblem constraint with a nonnegative violation variable _z (the GLM99 formulation the module cites) and minimizes it, so the subproblem is always feasible and generate_cut() returns a single blended optimality/feasibility cut — _z = 0 gives an optimality cut, _z > 0 certifies and cuts off recourse infeasibility. The RuntimeError is a genuine-solver-failure guard, not an optimality-only restriction.

Corrected the design doc's 'pyomo Benders gap' section: it now says pyomo already yields a valid cut for the LP-recourse infeasibility case (no Pyomo PR needed), with the two real caveats — dual-based so LP recourse only, and L1-penalty style rather than a dedicated Farkas cut, so worth revisiting. Pushing shortly.

Comment thread doc/src/xhat_feasibility_cuts.rst Outdated
LP. The upstream
``pyomo.contrib.benders.benders_cuts.BendersCutGeneratorData``
currently only produces optimality cuts; supporting the
infeasibility case is a Pyomo PR. Once that lands, the xhatter will

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

But, that said, it should probably be revisited, or we implement our own mechanism.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Agreed — updated the rst bullet to match. It now says pyomo already emits blended optimality/feasibility cuts for LP recourse in a single pass (so no Pyomo PR is needed for the infeasibility case), and flags that the L1-penalty blended cut is worth revisiting — we may tighten it or generate our own dedicated Farkas feasibility cut from the infeasible subproblem's dual ray. V1's binary-only no-good cut is unchanged; this only concerns the LP-recourse V2 path.

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

Tracked the V2 mechanism decision (reuse pyomo's blended cut / tighten it / roll our own Farkas cut from the dual ray) as #796 so it isn't buried in doc prose.

Pyomo's BendersCutGeneratorData does NOT produce optimality cuts only.
_setup_subproblem relaxes every subproblem constraint with a nonnegative
violation variable _z (the GLM99 formulation the module cites) and
minimizes it, so the subproblem is always feasible and generate_cut()
returns a single blended optimality/feasibility cut: _z=0 -> optimality
cut, _z>0 -> certifies and cuts off recourse infeasibility. The
RuntimeError is a genuine-solver-failure guard, not an optimality-only
restriction.

Correct both the design doc "pyomo Benders gap" section and the rst
Follow-up Milestones bullet: pyomo already yields a valid cut for the
LP-recourse infeasibility case (no Pyomo PR needed). The real caveats
are that it is dual-based (LP recourse only) and L1-penalty style rather
than a dedicated Farkas cut, so worth revisiting for V2. Per bknueven
review on PR Pyomo#671.

Co-Authored-By: Claude Opus 4.8 <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.

3 participants