From c6978fc8e594c2e0be65dd250f2351174a10c496 Mon Sep 17 00:00:00 2001 From: David L Woodruff Date: Mon, 11 May 2026 10:43:31 -0700 Subject: [PATCH 1/2] add --hub-only-solver-logs option When set together with --solver-log-dir, only the hub writes solver logs; spokes (xhat evaluators, bounders, etc.) do not. Useful to keep per-iteration hub-subproblem logs without an extra log file for every spoke solve. checker() requires --solver-log-dir to also be set. Co-Authored-By: Claude Opus 4.7 (1M context) --- mpisppy/utils/cfg_vanilla.py | 11 ++++++----- mpisppy/utils/config.py | 9 +++++++++ 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/mpisppy/utils/cfg_vanilla.py b/mpisppy/utils/cfg_vanilla.py index 96738fe9b..f316649e9 100644 --- a/mpisppy/utils/cfg_vanilla.py +++ b/mpisppy/utils/cfg_vanilla.py @@ -50,7 +50,7 @@ def _maybe_attach_jensens(spoke_dict, cfg, spoke_prefix, "scenario_creator_kwargs": scenario_creator_kwargs, } -def shared_options(cfg): +def shared_options(cfg, is_hub=False): shoptions = { "solver_name": cfg.solver_name, "defaultPHrho": cfg.default_rho, @@ -90,7 +90,8 @@ def shared_options(cfg): if _hasit(cfg, "reduced_costs"): shoptions["rc_bound_tol"] = cfg.rc_bound_tol if _hasit(cfg, "solver_log_dir"): - shoptions["solver_log_dir"] = cfg.solver_log_dir + if is_hub or not cfg.get("hub_only_solver_logs", False): + shoptions["solver_log_dir"] = cfg.solver_log_dir if _hasit(cfg, "obbt"): shoptions["presolve_options"] = { "obbt" : cfg.obbt, @@ -154,7 +155,7 @@ def ph_hub( ): from mpisppy.opt.ph import PH from mpisppy.cylinders.hub import PHHub - shoptions = shared_options(cfg) + shoptions = shared_options(cfg, is_hub=True) options = copy.deepcopy(shoptions) options["convthresh"] = cfg.intra_hub_conv_thresh @@ -272,7 +273,7 @@ def subgradient_hub(cfg, ): from mpisppy.opt.subgradient import Subgradient from mpisppy.cylinders.hub import SubgradientHub - shoptions = shared_options(cfg) + shoptions = shared_options(cfg, is_hub=True) options = copy.deepcopy(shoptions) options["convthresh"] = cfg.intra_hub_conv_thresh @@ -316,7 +317,7 @@ def fwph_hub(cfg, ): from mpisppy.opt.fwph import FWPH from mpisppy.cylinders.hub import FWPHHub - shoptions = shared_options(cfg) + shoptions = shared_options(cfg, is_hub=True) options = copy.deepcopy(shoptions) options["convthresh"] = cfg.intra_hub_conv_thresh diff --git a/mpisppy/utils/config.py b/mpisppy/utils/config.py index 94edd933c..1f9a9dfa6 100644 --- a/mpisppy/utils/config.py +++ b/mpisppy/utils/config.py @@ -165,6 +165,9 @@ def _bad_options(msg): if self.get("rc_fixer") and not self.get("reduced_costs"): _bad_options("--rc-fixer requires --reduced-costs") + if self.get("hub_only_solver_logs") and not self.get("solver_log_dir"): + _bad_options("--hub-only-solver-logs requires --solver-log-dir") + def add_solver_specs(self, prefix=""): sstr = f"{prefix}_solver" if prefix else "solver" if prefix: @@ -213,6 +216,12 @@ def popular_args(self): domain=str, default=None) + self.add_to_config("hub_only_solver_logs", + description="When set with --solver-log-dir, only the hub writes " + "solver logs; spokes do not. Requires --solver-log-dir.", + domain=bool, + default=False) + self.add_to_config("warmstart_subproblems", description="Warmstart subproblems from prior solution.", domain=bool, From f840a4359726f2873b0757663476d8e7a144e388 Mon Sep 17 00:00:00 2001 From: David L Woodruff Date: Mon, 11 May 2026 10:49:12 -0700 Subject: [PATCH 2/2] test: --hub-only-solver-logs checker + propagation Three checker cases (raise without --solver-log-dir; pass with it; pass when flag is off) and three shared_options propagation cases (both hub+spoke get the dir by default; hub-only suppresses spoke; no dir means no key anywhere). Co-Authored-By: Claude Opus 4.7 (1M context) --- mpisppy/tests/test_config.py | 58 ++++++++++++++++++++++++++++++++++++ 1 file changed, 58 insertions(+) diff --git a/mpisppy/tests/test_config.py b/mpisppy/tests/test_config.py index 1ff7de4e9..97c197858 100644 --- a/mpisppy/tests/test_config.py +++ b/mpisppy/tests/test_config.py @@ -445,6 +445,64 @@ def test_rc_fixer_with_reduced_costs_does_not_raise(self): cfg = self._make_rho_cfg(rc_fixer=True, reduced_costs=True) cfg.checker() + def _add_log_dir_keys(self, cfg, hub_only=False, log_dir=None): + cfg.add_to_config("hub_only_solver_logs", description="x", + domain=bool, default=hub_only, argparse=False) + cfg.add_to_config("solver_log_dir", description="x", + domain=str, default=log_dir, argparse=False) + + def test_hub_only_solver_logs_without_log_dir_raises(self): + cfg = self._make_rho_cfg() + self._add_log_dir_keys(cfg, hub_only=True, log_dir=None) + with self.assertRaises(ValueError): + cfg.checker() + + def test_hub_only_solver_logs_with_log_dir_does_not_raise(self): + cfg = self._make_rho_cfg() + self._add_log_dir_keys(cfg, hub_only=True, log_dir="/tmp/logs") + cfg.checker() + + def test_hub_only_solver_logs_default_off_does_not_raise(self): + cfg = self._make_rho_cfg() + self._add_log_dir_keys(cfg, hub_only=False, log_dir=None) + cfg.checker() + + +class TestSharedOptionsLogDir(unittest.TestCase): + """Tests for solver_log_dir propagation through cfg_vanilla.shared_options.""" + + def _make_cfg(self, log_dir=None, hub_only=False): + cfg = Config() + cfg.popular_args() + cfg.solver_name = "gurobi" + cfg.solver_log_dir = log_dir + cfg.hub_only_solver_logs = hub_only + return cfg + + def test_log_dir_propagates_to_hub_and_spoke_by_default(self): + import mpisppy.utils.cfg_vanilla as vanilla + cfg = self._make_cfg(log_dir="/tmp/logs", hub_only=False) + self.assertEqual(vanilla.shared_options(cfg, is_hub=True)["solver_log_dir"], + "/tmp/logs") + self.assertEqual(vanilla.shared_options(cfg, is_hub=False)["solver_log_dir"], + "/tmp/logs") + + def test_hub_only_suppresses_spoke_but_keeps_hub(self): + import mpisppy.utils.cfg_vanilla as vanilla + cfg = self._make_cfg(log_dir="/tmp/logs", hub_only=True) + self.assertEqual(vanilla.shared_options(cfg, is_hub=True)["solver_log_dir"], + "/tmp/logs") + self.assertNotIn("solver_log_dir", + vanilla.shared_options(cfg, is_hub=False)) + + def test_no_log_dir_no_key_anywhere(self): + import mpisppy.utils.cfg_vanilla as vanilla + cfg = self._make_cfg(log_dir=None, hub_only=False) + self.assertNotIn("solver_log_dir", + vanilla.shared_options(cfg, is_hub=True)) + self.assertNotIn("solver_log_dir", + vanilla.shared_options(cfg, is_hub=False)) + class TestConfigFixerArgs(unittest.TestCase): """Tests for Config.fixer_args() and related extension args."""