Skip to content

Supply an initial xhat from a file (--xhat-from-file)#672

Merged
DLWoodruff merged 4 commits into
Pyomo:mainfrom
DLWoodruff:xhat_from_file_design
Apr 26, 2026
Merged

Supply an initial xhat from a file (--xhat-from-file)#672
DLWoodruff merged 4 commits into
Pyomo:mainfrom
DLWoodruff:xhat_from_file_design

Conversation

@DLWoodruff
Copy link
Copy Markdown
Collaborator

@DLWoodruff DLWoodruff commented Apr 24, 2026

Design document: doc/xhat_from_file_design.md — please read that first.

Summary

Every xhat spoke now optionally reads a first-stage xhat vector from a .npy file, evaluates it across all scenarios once via Xhat_Eval.evaluate, and reports the resulting inner bound — before the spoke's normal exploration loop starts.

  • Off by default. Enabled with --xhat-from-file <path>.
  • Two-stage only (matches ciutils.read_xhat).
  • Works for every xhat spoke (xhatlooper, xhatshufflelooper, xhatspecific, xhatxbar) — the helper lives on XhatInnerBoundBase.xhat_prep, so all four inherit it for free.

Use cases

  1. Warm start from a prior run on a similar instance. If you've already solved a related instance, the prior run's xhat is often a good starting candidate — feed it in and the xhatter reports that inner bound immediately.
  2. User-supplied heuristic candidate.
  3. Testing infeasibility-driven features. Combined with --xhat-feasibility-cuts-count (PR Optional xhatter feasibility cuts, V1 (binary first-stage) — addresses #601 #671), supply a deliberately infeasible binary vector to exercise the feasibility-cut emission path end-to-end.

What this PR adds

  • cfg.xhat_from_file_args() registering the CLI flag.
  • XhatInnerBoundBase._try_file_xhat() — loads via ciutils.read_xhat, validates (file exists, length matches root-node nonants, two-stage), calls self.opt.evaluate, reports via self.update_if_improving if finite, restores nonants either way.
  • cfg_vanilla.shared_options propagates the path to every xhat spoke.
  • Wired into generic_cylinders via mpisppy/generic/parsing.py.
  • User-facing doc/src/xhat_from_file.rst, wired into index.rst.

Hard-fail paths

The feature refuses to silently skip; all of these raise at xhat_prep time:

  • Missing file.
  • Length mismatch between the file's vector and the problem's root-node nonant count.
  • Multi-stage problem (raised with a clear message naming the design doc).

Precedence with Jensen's (#657)

If both --xhat-from-file and --*-try-jensens-first are set, file-supplied is evaluated first, then Jensen's, then normal exploration. Correctness is order-invariant (update_if_improving keeps whichever is best); the ordering is a predictability / log-readability choice.

Test plan

  • ruff check mpisppy/ — clean.
  • python -m unittest mpisppy.tests.test_xhat_from_file — 11 tests pass. Covers off-by-default, missing file, multi-stage reject, length mismatch, happy path (finite Eobj → update_if_improving), infeasible-style (inf/None) Eobj, and math.isfinite sanity.
  • python -m unittest mpisppy.tests.test_ef_ph mpisppy.tests.test_ph_extensions — 34 existing xhat/PH tests still pass.
  • cd doc && make html — no new warnings.
  • Full CI.

Follow-ups

🤖 Generated with Claude Code

DLWoodruff and others added 2 commits April 23, 2026 17:51
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 Pyomo#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>
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>
@codecov
Copy link
Copy Markdown

codecov Bot commented Apr 24, 2026

Codecov Report

✅ All modified and coverable lines are covered by tests.
✅ Project coverage is 70.81%. Comparing base (def8967) to head (b1c2bb6).
⚠️ Report is 1 commits behind head on main.

Additional details and impacted files
@@            Coverage Diff             @@
##             main     #672      +/-   ##
==========================================
+ Coverage   70.74%   70.81%   +0.06%     
==========================================
  Files         153      153              
  Lines       19011    19040      +29     
==========================================
+ Hits        13450    13483      +33     
+ Misses       5561     5557       -4     

☔ View full report in Codecov by Sentry.
📢 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.

Add three focused tests that bring patch coverage on Pyomo#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>
@bknueven bknueven marked this pull request as ready for review April 24, 2026 18:08
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 Pyomo#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>
@DLWoodruff DLWoodruff merged commit d0a1324 into Pyomo:main Apr 26, 2026
30 checks passed
@DLWoodruff DLWoodruff deleted the xhat_from_file_design branch April 26, 2026 21:52
DLWoodruff added a commit to DLWoodruff/mpi-sppy-1 that referenced this pull request Apr 26, 2026
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>
DLWoodruff added a commit to DLWoodruff/mpi-sppy-1 that referenced this pull request Apr 26, 2026
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>
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.

2 participants