From 07a6e9d2f391e3a99470a45a69e4a08632612dd5 Mon Sep 17 00:00:00 2001 From: Dave Woodruff Date: Thu, 16 Jul 2026 14:36:42 -0700 Subject: [PATCH 1/6] =?UTF-8?q?docs(solver-options):=20mark=20=C2=A74=20op?= =?UTF-8?q?en=20questions=20#1=20and=20#2=20resolved?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Ratify the DLW decisions against the shipped implementation: - #1 options-file: JSON only (no YAML); inline --solver-options overlays the file (load_solver_options_file / cfg_vanilla ordering). - #2 spoke-override: flat key-level dict.update() onto the global set. Open questions #3-#5 in §4 are untouched (still to be walked through). Co-Authored-By: Claude Opus 4.8 --- doc/designs/solver_options_redesign.md | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/doc/designs/solver_options_redesign.md b/doc/designs/solver_options_redesign.md index ba17edf95..e3271d2bc 100644 --- a/doc/designs/solver_options_redesign.md +++ b/doc/designs/solver_options_redesign.md @@ -387,14 +387,19 @@ remains: are both supplied, who wins? Proposal to discuss: file is the base, inline string overlays. (CLI overlays file feels right because the inline string is the more "immediate" surface.) -DLW: CLI overlays +DLW: CLI overlays. **Resolved:** JSON only (no YAML); the inline +`--solver-options` string overlays the options-file. Implemented in +`load_solver_options_file` (json.load) and the file→inline ordering in +`cfg_vanilla`. 2. **Spoke-override merge depth.** Flat dict union, or anything more structured? Today's surface is flat (`{key: value}`), so a flat union is the minimum-change implementation. Anything richer would only matter if we add nested per-iteration sub-dicts (see #3). -DLW: flat union makes sense +DLW: flat union makes sense. **Resolved:** per-spoke options are a flat +key-level `dict.update()` onto the global set (implemented in +`cfg_vanilla`); the spoke wins on the keys it names and adds new ones. 3. **"After-iteration-N" surface.** How does the user specify N? Two sketches: From c4d24eeef3ef7246c090093389bca6202c458e7d Mon Sep 17 00:00:00 2001 From: Dave Woodruff Date: Thu, 16 Jul 2026 14:58:44 -0700 Subject: [PATCH 2/6] =?UTF-8?q?docs(solver-options):=20resolve=20=C2=A74?= =?UTF-8?q?=20open=20questions=20#3=20and=20#4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - #3 after-iteration-N: file-only (starting_at_iter section); it overrides iterk per-key for iterations k >= N. - #4 lagranger deprecation: shipped in PR #699 as a rank-0-gated DeprecationWarning; removal timeline intentionally left open. #1, #2 were resolved in the prior commit; #5 was already marked resolved in the doc. Co-Authored-By: Claude Opus 4.8 --- doc/designs/solver_options_redesign.md | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/doc/designs/solver_options_redesign.md b/doc/designs/solver_options_redesign.md index e3271d2bc..8ed07b1a5 100644 --- a/doc/designs/solver_options_redesign.md +++ b/doc/designs/solver_options_redesign.md @@ -410,6 +410,10 @@ key-level `dict.update()` onto the global set (implemented in File-only keeps the CLI surface flat and avoids inventing many new flags. Probably the right call if the file format lands first. DLW: File only. But the file will have to override iterk values or it won't make sense, right? +**Resolved:** file-only (a `starting_at_iter` section in the options-file; +no CLI flag). For iterations k >= N, `starting_at_iter:N` overrides `iterk` +on the keys it names (fold order default -> iter0/iterk -> starting_at_iter, +last-write-wins per key). 4. **Lagranger deprecation specifics.** Direction agreed: lagranger's custom iter0/iterk handling is deprecated; it routes through the @@ -419,6 +423,11 @@ DLW: File only. But the file will have to override iterk values or it won't make Open: warning message text and removal timeline. DLW: Open timeline. Just say that Lagranger will be deprecated in the future because it does not seem to work as well as other outer bound options. +**Resolved:** shipped (PR #699). `lagranger_spoke()` emits a rank-0-gated +`DeprecationWarning` at setup -- lagranger is slated for removal because it +underperforms the other outer-bound options (`--lagrangian`, `--ph-dual`, +`--subgradient`, `--fwph`); no removal timeline is committed. Timeline +intentionally left open per this decision. 5. **Per-spoke `--mipgaps-json` variants.** Today only the global `--mipgaps-json` flag is registered (config.py:616, gated on From 6b81eb5a672aff67e0eb221d6e5e3794df50c11b Mon Sep 17 00:00:00 2001 From: Dave Woodruff Date: Thu, 16 Jul 2026 15:22:31 -0700 Subject: [PATCH 3/6] feat(cfg): per-spoke solver name/options for the xhat inner-bound spokes The outer-bound spokes (lagrangian, reduced_costs, subgradient, ph_dual, relaxed_ph) already accept ---solver-name / -solver-options / -solver-options-file, but the xhat inner-bound spokes did not: their *_args helpers never called add_solver_specs, and their factories never called apply_solver_specs. Register the full solver trio in xhatlooper_args, xhatshuffle_args, xhatspecific_args, xhatxbar_args, xhatlshaped_args, and consume it in the matching spoke factories. apply_solver_specs updates iter0/iterk_solver_ options in place, so the nested xhat_solver_options reference stays valid. Because the three flags register independently, "options but not a different solver" also works now (supply only ---solver-options). Adds regression tests to test_solver_options_layers.py: - all five xhat *_args register the solver trio - xhatshuffle_spoke routes per-spoke solver name + options into the spoke Co-Authored-By: Claude Opus 4.8 --- mpisppy/tests/test_solver_options_layers.py | 64 ++++++++++++++++++++- mpisppy/utils/cfg_vanilla.py | 9 +++ mpisppy/utils/config.py | 10 ++++ 3 files changed, 82 insertions(+), 1 deletion(-) diff --git a/mpisppy/tests/test_solver_options_layers.py b/mpisppy/tests/test_solver_options_layers.py index 38cdfb6fb..ffc315d93 100644 --- a/mpisppy/tests/test_solver_options_layers.py +++ b/mpisppy/tests/test_solver_options_layers.py @@ -16,7 +16,12 @@ import unittest from mpisppy.utils import config -from mpisppy.utils.cfg_vanilla import shared_options, apply_solver_specs +from mpisppy.utils.cfg_vanilla import ( + shared_options, + apply_solver_specs, + xhatshuffle_spoke, +) +from mpisppy.generic.parsing import add_decomp_args from mpisppy.utils.sputils import ( fold_solver_options_layers, solver_options_layer, @@ -1204,5 +1209,62 @@ def test_iterk_solver_options_property_read_warns(self): ) +class TestXhatSpokePerSpokeSolver(unittest.TestCase): + """The xhat (inner-bound) spokes expose and consume the same + per-spoke solver surface as the outer-bound spokes: each xhat + *_args helper registers the ---solver-{name,options, + options-file} trio, and each xhat spoke factory routes those + values into the spoke via apply_solver_specs. + """ + + XHAT_ARGS = [ + ("xhatlooper", "xhatlooper_args"), + ("xhatshuffle", "xhatshuffle_args"), + ("xhatspecific", "xhatspecific_args"), + ("xhatxbar", "xhatxbar_args"), + ("xhatlshaped", "xhatlshaped_args"), + ] + + def test_xhat_args_register_full_solver_trio(self): + for prefix, argfn in self.XHAT_ARGS: + cfg = config.Config() + cfg.popular_args() + getattr(cfg, argfn)() + for suffix in ("_solver_name", "_solver_options", + "_solver_options_file"): + self.assertIn( + prefix + suffix, cfg, + f"{argfn} did not register {prefix + suffix}", + ) + + def test_xhatshuffle_spoke_routes_per_spoke_solver(self): + # The spoke factory must call apply_solver_specs so the per-spoke + # solver name and options actually reach the spoke's option dict + # (and the nested xhat_solver_options that references iterk). + cfg = config.Config() + cfg.popular_args() + add_decomp_args(cfg) + cfg.default_rho = 1.0 + cfg.solver_name = "gurobi" # hub / global + cfg.solver_options = "mipgap=0.1" # global options + cfg.xhatshuffle = True + cfg.xhatshuffle_solver_name = "xpress" # override + cfg.xhatshuffle_solver_options = "mipgap=0.001 threads=2" + + def _sc(*a, **k): # the foundation packages, never calls, this + raise AssertionError("scenario_creator should not be called") + + spoke = xhatshuffle_spoke(cfg, _sc, None, ["scen0", "scen1", "scen2"]) + opts = spoke["opt_kwargs"]["options"] + self.assertEqual(opts["solver_name"], "xpress") + self.assertEqual(opts["iterk_solver_options"]["mipgap"], 0.001) + self.assertEqual(opts["iterk_solver_options"]["threads"], 2) + # the nested reference must see the per-spoke options too + self.assertEqual( + opts["xhat_looper_options"]["xhat_solver_options"]["mipgap"], + 0.001, + ) + + if __name__ == "__main__": unittest.main() diff --git a/mpisppy/utils/cfg_vanilla.py b/mpisppy/utils/cfg_vanilla.py index 6822a7d23..eced4f900 100644 --- a/mpisppy/utils/cfg_vanilla.py +++ b/mpisppy/utils/cfg_vanilla.py @@ -1421,6 +1421,8 @@ def xhatlooper_spoke( extension_kwargs=extension_kwargs, ) + apply_solver_specs("xhatlooper", xhatlooper_dict, cfg) + xhatlooper_dict["opt_kwargs"]["options"]["xhat_looper_options"] = { "xhat_solver_options": xhatlooper_dict["opt_kwargs"]["options"]["iterk_solver_options"], "scen_limit": cfg.xhat_scen_limit, @@ -1461,6 +1463,8 @@ def xhatxbar_spoke( all_nodenames=all_nodenames, ) + apply_solver_specs("xhatxbar", xhatxbar_dict, cfg) + xhatxbar_dict["opt_kwargs"]["options"]["xhat_xbar_options"] = { "xhat_solver_options": xhatxbar_dict["opt_kwargs"]["options"]["iterk_solver_options"], "dump_prefix": "delme", @@ -1501,6 +1505,8 @@ def xhatshuffle_spoke( ph_extensions=ph_extensions, extension_kwargs=extension_kwargs, ) + apply_solver_specs("xhatshuffle", xhatshuffle_dict, cfg) + xhatshuffle_dict["opt_kwargs"]["options"]["xhat_looper_options"] = { "xhat_solver_options": xhatshuffle_dict["opt_kwargs"]["options"]["iterk_solver_options"], "dump_prefix": "delme", @@ -1544,6 +1550,8 @@ def xhatspecific_spoke( ph_extensions=ph_extensions, extension_kwargs=extension_kwargs, ) + apply_solver_specs("xhatspecific", xhatspecific_dict, cfg) + xhatspecific_dict["opt_kwargs"]["options"]["xhat_specific_options"] = { "xhat_solver_options": xhatspecific_dict["opt_kwargs"]["options"]["iterk_solver_options"], "xhat_scenario_dict": scenario_dict, @@ -1576,6 +1584,7 @@ def xhatlshaped_spoke( ph_extensions=ph_extensions, extension_kwargs=extension_kwargs, ) + apply_solver_specs("xhatlshaped", xhatlshaped_dict, cfg) return xhatlshaped_dict def slammax_spoke( diff --git a/mpisppy/utils/config.py b/mpisppy/utils/config.py index f0badce7f..6ff2d5ef5 100644 --- a/mpisppy/utils/config.py +++ b/mpisppy/utils/config.py @@ -1198,6 +1198,8 @@ def xhatlooper_args(self): domain=bool, default=False) + self.add_solver_specs("xhatlooper") + def xhatshuffle_args(self): self.add_to_config('xhatshuffle', @@ -1243,6 +1245,8 @@ def xhatshuffle_args(self): self.add_stage2_ef_solver_name_arg() + self.add_solver_specs("xhatshuffle") + def mult_rho_args(self): @@ -1303,6 +1307,8 @@ def xhatspecific_args(self): domain=bool, default=False) + self.add_solver_specs("xhatspecific") + def xhatxbar_args(self): @@ -1337,6 +1343,8 @@ def xhatxbar_args(self): domain=bool, default=False) + self.add_solver_specs("xhatxbar") + def xhatlshaped_args(self): # we will not try to get the specification from the command line @@ -1346,6 +1354,8 @@ def xhatlshaped_args(self): domain=bool, default=False) + self.add_solver_specs("xhatlshaped") + def xhat_from_file_args(self): # Supply an initial xhat candidate from a file. Every xhat spoke # (xhatlooper, xhatshufflelooper, xhatspecific, xhatxbar) that From 42f96723ec7858de1540e08e0527d0b79823a314 Mon Sep 17 00:00:00 2001 From: Dave Woodruff Date: Tue, 21 Jul 2026 09:23:09 -0700 Subject: [PATCH 4/6] Reword garbled _sc stub comment in per-spoke solver routing test Addresses Copilot review feedback: the inline comment was a fragment. Clarify that the spoke factory only packages config and never invokes scenario_creator. Co-Authored-By: Claude Opus 4.8 --- mpisppy/tests/test_solver_options_layers.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/mpisppy/tests/test_solver_options_layers.py b/mpisppy/tests/test_solver_options_layers.py index ffc315d93..d60a4b71d 100644 --- a/mpisppy/tests/test_solver_options_layers.py +++ b/mpisppy/tests/test_solver_options_layers.py @@ -1251,7 +1251,7 @@ def test_xhatshuffle_spoke_routes_per_spoke_solver(self): cfg.xhatshuffle_solver_name = "xpress" # override cfg.xhatshuffle_solver_options = "mipgap=0.001 threads=2" - def _sc(*a, **k): # the foundation packages, never calls, this + def _sc(*a, **k): # spoke factory only packages config; never calls this raise AssertionError("scenario_creator should not be called") spoke = xhatshuffle_spoke(cfg, _sc, None, ["scen0", "scen1", "scen2"]) From 53d4b6d74f670468028a58c7ce268610e6a15b25 Mon Sep 17 00:00:00 2001 From: Dave Woodruff Date: Tue, 21 Jul 2026 11:21:40 -0700 Subject: [PATCH 5/6] =?UTF-8?q?Refresh=20=C2=A71/Status=20in=20solver-opti?= =?UTF-8?q?ons=20design=20doc?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The top Status line still claimed only phase 1 had landed with phases 2-8 scheduled; in fact all eight §6.4 rollout phases have shipped. Update it to say so, listing the live layered representation and the translation / overlay / schedule-as-layer / options-file / deprecation-warning pieces. Refresh §1 (current state) to match: add the five xhat inner-bound spokes' per-spoke --{name}-solver-name / --{name}-solver-options flags to the §1.1 inventory (added in this PR), and add a §1.5 note recording which pitfalls the shipped phases resolved (items 3, 4, 9) versus which still describe live behavior. Co-Authored-By: Claude Opus 4.8 --- doc/designs/solver_options_redesign.md | 33 ++++++++++++++++++++++---- 1 file changed, 28 insertions(+), 5 deletions(-) diff --git a/doc/designs/solver_options_redesign.md b/doc/designs/solver_options_redesign.md index 8ed07b1a5..c3d72edb0 100644 --- a/doc/designs/solver_options_redesign.md +++ b/doc/designs/solver_options_redesign.md @@ -1,10 +1,15 @@ # Solver-options redesign -Status: design complete; phased implementation in progress. This document -covers the current state (§1), goals and non-goals (§2–3), resolved open -questions (§4), the proposed design (§5), and the migration / compatibility -plan (§6). Phase 1 (dormant layered representation) lands with this doc; -phases 2–8 are scheduled per §6.4. +Status: design complete; implementation shipped. This document covers the +solver-options surface as it stands today (§1), goals and non-goals +(§2–3), resolved open questions (§4), the design (§5), and the migration / +compatibility plan (§6). All eight rollout phases in §6.4 have landed: the +layered representation (`solver_options_layers`) is live and consumed via +`_effective_solver_options`, and solver-name translation (`mipgap` / +`threads`), per-spoke overlay merge, the mipgap-schedule-as-layer path, the +options-file loader, and the lagranger / programmatic-API deprecation +warnings are all in the code. §1 is kept current as phases land; §6.4 +carries the per-phase detail. Backward-compatibility constraint: every CLI flag and CLI value-syntax that works today must continue to work after the redesign. Programmatic-API @@ -117,6 +122,14 @@ The flags actually exposed today, by group: - `--ph-dual-solver-name`, `--ph-dual-solver-options` - `--lagranger-solver-name`, `--lagranger-solver-options` (lagranger has its own ad-hoc iter0/iterk wiring; see §1.5) +- `--xhatlooper-solver-name`, `--xhatlooper-solver-options` +- `--xhatshuffle-solver-name`, `--xhatshuffle-solver-options` +- `--xhatspecific-solver-name`, `--xhatspecific-solver-options` +- `--xhatxbar-solver-name`, `--xhatxbar-solver-options` +- `--xhatlshaped-solver-name`, `--xhatlshaped-solver-options` (the five + xhat inner-bound spokes now carry the same per-spoke + `--{name}-solver-name` / `--{name}-solver-options` pair as the + outer-bound spokes; their factories apply it via `apply_solver_specs`) - `--obbt-solver-options` — config.py:325, OBBT presolve only - `--pickle-solver-name`, `--pickle-solver-options` — config.py:1256/1263, used for the iter0 solve done at pickle time @@ -290,6 +303,16 @@ on, and ideally fix: adaptive — it reads the hub/spoke bound gap each iteration — so it cannot be expressed as a static layer. +Implementation status of these pitfalls: items 3, 4, and 9 have since been +addressed by the shipped redesign and are retained here as the motivation +that drove it — solver-name-aware translation for `mipgap` / `threads` +(item 3; §5, phase 3), per-spoke overlay-instead-of-replace merge (item 4; +§6.2, phase 4), and the `--mipgaps-json` schedule folded into +`solver_options_layers` (item 9; phase 5). Lagranger (item 7) now emits a +rank-0-gated `DeprecationWarning` (§5.8, phase 7), though its ad-hoc +iter0/iterk wiring is intentionally left in place (§6.7). The remaining +items still describe live behavior. + ### 1.6 Representative current usage CLI (from `examples/run_uc.py:96`): From a4b6eb9411327bdd0c556ba265d1069e6ad7f775 Mon Sep 17 00:00:00 2001 From: Dave Woodruff Date: Sat, 8 Aug 2026 08:52:28 -0700 Subject: [PATCH 6/6] Reconcile solver-options doc surfaces; document per-spoke solver-name MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review follow-ups on the per-spoke-solver work: - solver_options_redesign.md §1.4 still described apply_solver_specs as overwriting the iter0/iterk dicts wholesale, which contradicted §1.5's own note that phase 4 replaced that with an overlay. Describe the overlay semantics and drop the stale line-number anchors. - §6.4 was still written as a forward-looking plan ("should land", "Suggested order") even though the refreshed Status header says all eight phases shipped. Put the preamble and closing note in past tense and drop the now-redundant "Shipped." marker on phase 8, which was the only one of the eight carrying it. - generic_cylinders.rst documented ---solver-options but never ---solver-name. Add it with the hub-on-one-solver / spoke-on-another example, name the three xhat spokes generic_cylinders exposes, and note FWPH's two-solver flags as the exception. - Add a routing test for xhatlshaped_spoke. It is the one xhat factory with no nested xhat_solver_options dict, and it had no unit coverage at all -- it was only reached end-to-end via examples/run_all.py and generic_tester.py. Co-Authored-By: Claude Opus 5 --- doc/designs/solver_options_redesign.md | 30 +++++++++++---------- doc/src/generic_cylinders.rst | 22 +++++++++++++++ mpisppy/tests/test_solver_options_layers.py | 28 +++++++++++++++++++ 3 files changed, 66 insertions(+), 14 deletions(-) diff --git a/doc/designs/solver_options_redesign.md b/doc/designs/solver_options_redesign.md index c3d72edb0..b88f9dec7 100644 --- a/doc/designs/solver_options_redesign.md +++ b/doc/designs/solver_options_redesign.md @@ -240,12 +240,14 @@ previous one, so later steps win: 5. If `--iterk-mipgap`, write `iterk_solver_options["mipgap"]` (cfg_vanilla.py:88-89). -`apply_solver_specs(name, spoke, cfg)` (cfg_vanilla.py:113–129) then runs -*per spoke that opted in*, with the same shape but reading -`{name}_solver_options`, `{name}_iter0_mipgap`, etc. Important quirk: -after potentially overwriting iter0/iterk dicts wholesale at line 119-120, -it **re-applies** `--max-solver-threads` at lines 127-129 to keep the -global thread cap honored. +`apply_solver_specs(name, spoke, cfg)` then runs *per spoke that opted +in*, with the same shape but reading `{name}_solver_options`, +`{name}_iter0_mipgap`, etc. It **overlays** those onto the iter0/iterk +dicts `shared_options` already built — a key-level `dict.update()`, so +the spoke wins on the keys it names and inherits the rest. (Before the +redesign it replaced those dicts wholesale; see §1.5 item 4.) It +re-applies `--max-solver-threads` last, so the global thread cap wins +even when a spoke names its own `threads`. ### 1.5 Asymmetries and pitfalls already in the as-is @@ -889,9 +891,9 @@ that will need migration. ### 6.4 Phased rollout -The redesign is large enough that it should land in review-sized -phases, each independently testable. Suggested order — each phase is -green-on-its-own: +The redesign was large enough to land in review-sized phases, each +independently testable and green on its own. All eight phases below +have shipped; they landed in this order: 1. **Layer data model (no behavior change) + this design document.** Add `solver_options_layers` to `PHBase` alongside the existing @@ -921,7 +923,7 @@ green-on-its-own: spoke variants); add `load_solver_options_file`; plumb file layers in `shared_options` / `apply_solver_specs`. 7. **Lagranger deprecation warning** (§5.8). Single-line addition. -8. **Programmatic-API deprecation warnings** (§6.3). **Shipped.** +8. **Programmatic-API deprecation warnings** (§6.3). `options["iter0_solver_options"]` / `options["iterk_solver_options"]` dict input and `PHBase.iter0_solver_options` / `iterk_solver_options` attribute reads now emit @@ -929,10 +931,10 @@ green-on-its-own: until the spokes that still read it migrate to the layer system. -Phases 1–2 land internally with no surface change. Phase 4 is the only -phase with a release-notes-worthy behavior change. Phases 6 and 7 add -new surface (new flag, new warning); phases 3 and 5 add new behavior -that improves on quietly-broken cases. +Phases 1–2 landed internally with no surface change. Phase 4 was the +only phase with a release-notes-worthy behavior change. Phases 6 and 7 +added new surface (new flag, new warning); phases 3 and 5 added new +behavior that improves on quietly-broken cases. ### 6.5 Test coverage diff --git a/doc/src/generic_cylinders.rst b/doc/src/generic_cylinders.rst index 227d12d91..e58c419ab 100644 --- a/doc/src/generic_cylinders.rst +++ b/doc/src/generic_cylinders.rst @@ -366,6 +366,28 @@ flag adds ``mipgap`` and leaves the global ``presolve`` and ``threads`` in place. The hub and the other spokes see the global dict ``{presolve=2, threads=4}`` unchanged. +Each spoke also takes ``---solver-name``, so a spoke can run +on a different solver from the hub. This covers the xhat inner-bound +spokes (``--xhatshuffle-solver-name``, ``--xhatxbar-solver-name``, +``--xhatlshaped-solver-name``) as well as the outer-bound ones: + +.. code-block:: bash + + --solver-name gurobi --xhatshuffle --xhatshuffle-solver-name xpress + +Here the PH hub solves on Gurobi and the xhatshuffle spoke's +incumbent-finding solves go to Xpress. ``---solver-name`` +falls back to ``--solver-name`` when it is not given, and the name +and options flags are independent — supply only +``---solver-options`` to keep the inherited solver but change +its options. + +.. note:: + FWPH is the exception to the ``---solver-name`` pattern: + it solves two kinds of subproblem and so takes + ``--fwph-mip-solver-name`` and ``--fwph-qp-solver-name`` + instead. See :ref:`Hubs`. + .. warning:: Behavior change in 2026: per-spoke solver-options flags diff --git a/mpisppy/tests/test_solver_options_layers.py b/mpisppy/tests/test_solver_options_layers.py index fa1a1c29a..91579acb3 100644 --- a/mpisppy/tests/test_solver_options_layers.py +++ b/mpisppy/tests/test_solver_options_layers.py @@ -19,6 +19,7 @@ from mpisppy.utils.cfg_vanilla import ( shared_options, apply_solver_specs, + xhatlshaped_spoke, xhatshuffle_spoke, ) from mpisppy.generic.parsing import add_decomp_args @@ -1486,6 +1487,33 @@ def _sc(*a, **k): # spoke factory only packages config; never calls this 0.001, ) + def test_xhatlshaped_spoke_routes_per_spoke_solver(self): + # xhatlshaped is the one xhat factory with no nested + # xhat_solver_options dict -- it applies the specs and returns -- + # so it gets its own routing check rather than riding on the + # xhatshuffle case above. + cfg = config.Config() + cfg.popular_args() + add_decomp_args(cfg) + cfg.default_rho = 1.0 + cfg.solver_name = "gurobi" # hub / global + cfg.solver_options = "presolve=2" # global options + cfg.xhatlshaped = True + cfg.xhatlshaped_solver_name = "xpress" # override + cfg.xhatlshaped_solver_options = "mipgap=0.001" + + def _sc(*a, **k): # spoke factory only packages config; never calls this + raise AssertionError("scenario_creator should not be called") + + spoke = xhatlshaped_spoke(cfg, _sc, None, ["scen0", "scen1", "scen2"]) + opts = spoke["opt_kwargs"]["options"] + self.assertEqual(opts["solver_name"], "xpress") + for when in ("iter0_solver_options", "iterk_solver_options"): + # per-spoke options overlay the global set: the spoke's + # mipgap lands and the global presolve survives + self.assertEqual(opts[when]["mipgap"], 0.001) + self.assertEqual(opts[when]["presolve"], 2) + if __name__ == "__main__": unittest.main()