From 1b1cecdfd2ba78cac4f7efe570b0b4d2c3a598b1 Mon Sep 17 00:00:00 2001 From: jlnav Date: Tue, 26 May 2026 10:01:30 -0500 Subject: [PATCH 01/18] set gen_specs.user.lb and ub from vocs variables bounds --- libensemble/specs.py | 19 ++++ .../regression_tests/test_asktell_gpCAM.py | 4 - libensemble/tests/unit_tests/test_ensemble.py | 97 +++++++++++++++++++ 3 files changed, 116 insertions(+), 4 deletions(-) diff --git a/libensemble/specs.py b/libensemble/specs.py index 9ee04baa3..c95bdd8f8 100644 --- a/libensemble/specs.py +++ b/libensemble/specs.py @@ -2,6 +2,7 @@ import warnings from pathlib import Path +import numpy as np import pydantic from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator @@ -354,6 +355,24 @@ def set_fields_from_vocs(self): if "_id" not in self.persis_in: self.persis_in.append("_id") + # Set user["lb"]/["ub"] from VOCS continuous variables (for legacy generators + # that read bounds from gen_specs["user"]). Skip variables without a ``.domain`` + # attribute (e.g., DiscreteVariable). Do not overwrite user-provided values. + if self.user is None: + self.user = {} + if "lb" not in self.user or "ub" not in self.user: + lbs, ubs = [], [] + for _name, var in (getattr(self.vocs, "variables", None) or {}).items(): + domain = getattr(var, "domain", None) + if domain is not None and len(domain) == 2: + lbs.append(domain[0]) + ubs.append(domain[1]) + if lbs: + if "lb" not in self.user: + self.user["lb"] = np.array(lbs, dtype=float) + if "ub" not in self.user: + self.user["ub"] = np.array(ubs, dtype=float) + return self @model_validator(mode="after") diff --git a/libensemble/tests/regression_tests/test_asktell_gpCAM.py b/libensemble/tests/regression_tests/test_asktell_gpCAM.py index f59fc135d..ca4ca3ef9 100644 --- a/libensemble/tests/regression_tests/test_asktell_gpCAM.py +++ b/libensemble/tests/regression_tests/test_asktell_gpCAM.py @@ -56,10 +56,6 @@ "persis_in": ["x", "f", "sim_id"], "out": [("x", float, (n,))], "batch_size": batch_size, - "user": { - "lb": np.array([-3, -2, -1, -1]), - "ub": np.array([3, 2, 1, 1]), - }, } vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2], "x2": [-1, 1], "x3": [-1, 1]}, objectives={"f": "MINIMIZE"}) diff --git a/libensemble/tests/unit_tests/test_ensemble.py b/libensemble/tests/unit_tests/test_ensemble.py index 0e5de3223..3ea2abccb 100644 --- a/libensemble/tests/unit_tests/test_ensemble.py +++ b/libensemble/tests/unit_tests/test_ensemble.py @@ -270,6 +270,97 @@ def test_ready_happy_path(): assert issues == [], f"Issues should be empty but got: {issues}" +def test_gen_specs_vocs_populates_user_bounds(): + """GenSpecs should populate user['lb']/['ub'] from VOCS continuous variables.""" + from gest_api.vocs import VOCS + + from libensemble.specs import GenSpecs + + vocs = VOCS( + variables={"x0": [-3, 3], "x1": [-2, 2], "x2": [-1, 1], "x3": [-1, 1]}, + objectives={"f": "MINIMIZE"}, + ) + gs = GenSpecs(vocs=vocs) + assert "lb" in gs.user, "lb should be populated in user from VOCS" + assert "ub" in gs.user, "ub should be populated in user from VOCS" + assert isinstance(gs.user["lb"], np.ndarray), "lb should be a numpy array" + assert isinstance(gs.user["ub"], np.ndarray), "ub should be a numpy array" + assert np.array_equal(gs.user["lb"], np.array([-3, -2, -1, -1])) + assert np.array_equal(gs.user["ub"], np.array([3, 2, 1, 1])) + + +def test_gen_specs_vocs_does_not_overwrite_user_bounds(): + """GenSpecs should not overwrite user-provided lb/ub when vocs is also given.""" + from gest_api.vocs import VOCS + + from libensemble.specs import GenSpecs + + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + explicit_lb = np.array([0.0, 0.0]) + explicit_ub = np.array([1.0, 1.0]) + gs = GenSpecs(vocs=vocs, user={"lb": explicit_lb, "ub": explicit_ub}) + assert np.array_equal(gs.user["lb"], explicit_lb), "Explicit lb should be preserved" + assert np.array_equal(gs.user["ub"], explicit_ub), "Explicit ub should be preserved" + + +def test_gen_specs_vocs_partial_user_bounds(): + """GenSpecs should fill in only the missing one of lb/ub if user supplies just one.""" + from gest_api.vocs import VOCS + + from libensemble.specs import GenSpecs + + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + explicit_lb = np.array([0.0, 0.0]) + gs = GenSpecs(vocs=vocs, user={"lb": explicit_lb}) + assert np.array_equal(gs.user["lb"], explicit_lb), "Explicit lb should be preserved" + assert "ub" in gs.user, "ub should be populated from VOCS" + assert np.array_equal(gs.user["ub"], np.array([3, 2])) + + +def test_gen_specs_no_vocs_leaves_user_empty(): + """Without VOCS, GenSpecs.user should remain empty by default.""" + from libensemble.specs import GenSpecs + + gs = GenSpecs(outputs=[("x", float, (1,))]) + assert "lb" not in gs.user, "lb should not be auto-populated without VOCS" + assert "ub" not in gs.user, "ub should not be auto-populated without VOCS" + + +def test_gen_specs_vocs_satisfies_legacy_user_params(): + """VOCS-populated user bounds should satisfy legacy gen_f consumers like + persistent_uniform (which require lb/ub to be numpy arrays and uses len(lb) + for dimension).""" + from gest_api.vocs import VOCS + + from libensemble.gen_funcs.persistent_sampling import _get_user_params + from libensemble.specs import GenSpecs + + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gs = GenSpecs(vocs=vocs, initial_batch_size=10) + + # Convert to dict shape that _get_user_params expects + gs_dict = {"initial_batch_size": gs.initial_batch_size, "user": gs.user} + b, n, lb, ub = _get_user_params(gs_dict["user"], gs_dict) + assert b == 10 + assert n == 2 + assert isinstance(lb, np.ndarray) and lb.dtype == float + assert isinstance(ub, np.ndarray) and ub.dtype == float + assert np.array_equal(lb, np.array([-3.0, -2.0])) + assert np.array_equal(ub, np.array([3.0, 2.0])) + + +def test_gen_specs_vocs_integer_domain_yields_float_array(): + """Integer-valued VOCS domains should still produce float dtype lb/ub arrays.""" + from gest_api.vocs import VOCS + + from libensemble.specs import GenSpecs + + vocs = VOCS(variables={"x0": [0, 10], "x1": [-5, 5]}, objectives={"f": "MINIMIZE"}) + gs = GenSpecs(vocs=vocs) + assert gs.user["lb"].dtype == float, "lb should be float dtype even for integer-domain variables" + assert gs.user["ub"].dtype == float, "ub should be float dtype even for integer-domain variables" + + if __name__ == "__main__": test_ensemble_init() test_ensemble_parse_args_false() @@ -283,3 +374,9 @@ def test_ready_happy_path(): test_ready_missing_nworkers_local() test_ready_field_mismatch() test_ready_happy_path() + test_gen_specs_vocs_populates_user_bounds() + test_gen_specs_vocs_does_not_overwrite_user_bounds() + test_gen_specs_vocs_partial_user_bounds() + test_gen_specs_no_vocs_leaves_user_empty() + test_gen_specs_vocs_satisfies_legacy_user_params() + test_gen_specs_vocs_integer_domain_yields_float_array() From 6a0e11215fa883b80cd4059ffe63f0cfcc1b7a25 Mon Sep 17 00:00:00 2001 From: jlnav Date: Wed, 27 May 2026 13:58:28 -0500 Subject: [PATCH 02/18] adjust many examples, docs, and tutorials to no longer mention gen-on-manager *or* the gen running on the first worker. adjust many tests to use VOCS instead of ub/lb bounds --- docs/platforms/frontier.rst | 5 ++--- docs/platforms/perlmutter.rst | 5 ++--- docs/tutorials/xopt_bayesian_gen.rst | 2 +- .../tutorials/gpcam_surrogate_model/gpcam.ipynb | 6 +++--- .../xopt_bayesian_gen/xopt_EI_example.ipynb | 2 +- .../functionality_tests/test_1d_super_simple.py | 8 ++++---- .../test_asktell_sampling.py | 4 ---- .../functionality_tests/test_calc_exception.py | 9 ++++----- .../functionality_tests/test_cancel_in_alloc.py | 16 +++++++++++----- .../tests/functionality_tests/test_comms.py | 8 ++++---- .../test_elapsed_time_abort.py | 9 ++++----- .../test_executor_forces_tutorial.py | 9 ++++----- .../test_executor_forces_tutorial_2.py | 9 ++++----- .../test_executor_hworld_pass_fail.py | 8 ++++---- .../test_executor_hworld_timeout.py | 8 ++++---- .../test_mpi_runners_subnode_uneven.py | 17 +++++++++++------ .../test_mpi_runners_supernode_uneven.py | 17 +++++++++++------ .../test_persistent_uniform_sampling_async.py | 8 ++++---- .../test_sim_dirs_per_calc.py | 9 ++++----- .../test_sim_dirs_per_worker.py | 9 ++++----- .../test_sim_dirs_with_exception.py | 9 ++++----- .../test_sim_input_dir_option.py | 9 ++++----- .../test_uniform_sampling.py | 8 ++++---- .../test_worker_exceptions.py | 11 +++++------ .../functionality_tests/test_workflow_dir.py | 9 ++++----- .../test_xopt_EI_initial_sample.py | 3 +-- .../test_xopt_EI_initial_sample_instance.py | 3 +-- 27 files changed, 109 insertions(+), 111 deletions(-) diff --git a/docs/platforms/frontier.rst b/docs/platforms/frontier.rst index a57ffadd9..ef2251b32 100644 --- a/docs/platforms/frontier.rst +++ b/docs/platforms/frontier.rst @@ -64,10 +64,9 @@ Now grab an interactive session on one node:: Then in the session run:: - python run_libe_forces.py --nworkers 9 + python run_libe_forces.py --nworkers 8 -This places the generator on the first worker and runs simulations on the -others (each simulation using one GPU). +The workers will each run simulations with one GPU each. To see GPU usage, ssh into the node you are on in another window and run:: diff --git a/docs/platforms/perlmutter.rst b/docs/platforms/perlmutter.rst index a1c79703f..7f6098657 100644 --- a/docs/platforms/perlmutter.rst +++ b/docs/platforms/perlmutter.rst @@ -96,10 +96,9 @@ Now grab an interactive session on one node:: Then in the session run:: export LIBE_PLATFORM="perlmutter_g" - python run_libe_forces.py -n 5 + python run_libe_forces.py -n 4 -This places the generator on the first worker and runs simulations on the -others (each simulation using one GPU). +The workers will each run simualations with one GPU each. To see GPU usage, ssh into the node you are on in another window and run:: diff --git a/docs/tutorials/xopt_bayesian_gen.rst b/docs/tutorials/xopt_bayesian_gen.rst index 9227ac8ce..14e3b50b9 100644 --- a/docs/tutorials/xopt_bayesian_gen.rst +++ b/docs/tutorials/xopt_bayesian_gen.rst @@ -52,7 +52,7 @@ Define the VOCS specification and set up the generator. .. code-block:: python - libE_specs = LibeSpecs(gen_on_manager=True, nworkers=4) + libE_specs = LibeSpecs(nworkers=4) vocs = VOCS( variables={"x1": [0, 1.0], "x2": [0, 10.0]}, diff --git a/examples/tutorials/gpcam_surrogate_model/gpcam.ipynb b/examples/tutorials/gpcam_surrogate_model/gpcam.ipynb index 29616f582..53fdfd4a0 100644 --- a/examples/tutorials/gpcam_surrogate_model/gpcam.ipynb +++ b/examples/tutorials/gpcam_surrogate_model/gpcam.ipynb @@ -285,9 +285,9 @@ "\n", "nworkers = 4\n", "\n", - "# When using gen_on_manager, nworkers is number of concurrent sims.\n", + "# nworkers is number of concurrent sims.\n", "# final_gen_send means the last evaluated points are returned to the generator to update the model.\n", - "libE_specs = LibeSpecs(nworkers=nworkers, gen_on_manager=True, final_gen_send=True)\n", + "libE_specs = LibeSpecs(nworkers=nworkers, final_gen_send=True)\n", "\n", "n = 2 # Input dimensions\n", "batch_size = 4\n", @@ -400,7 +400,7 @@ "import matplotlib\n", "import matplotlib.pyplot as plt\n", "\n", - "# Get \"mean_squared_error\" from generators return (worker 0 as we ran gen_on_manager)\n", + "# Get \"mean_squared_error\" from generators return\n", "mse = persis_info[0][\"mean_squared_error\"]\n", "niter = len(mse)\n", "num_sims = list(range(batch_size, (niter * batch_size) + 1, batch_size))\n", diff --git a/examples/tutorials/xopt_bayesian_gen/xopt_EI_example.ipynb b/examples/tutorials/xopt_bayesian_gen/xopt_EI_example.ipynb index bc27f41a2..140b961a4 100644 --- a/examples/tutorials/xopt_bayesian_gen/xopt_EI_example.ipynb +++ b/examples/tutorials/xopt_bayesian_gen/xopt_EI_example.ipynb @@ -98,7 +98,7 @@ "metadata": {}, "outputs": [], "source": [ - "libE_specs = LibeSpecs(gen_on_manager=True, nworkers=4)\n", + "libE_specs = LibeSpecs(nworkers=4)\n", "\n", "vocs = VOCS(\n", " variables={\"x1\": [0, 1.0], \"x2\": [0, 10.0]},\n", diff --git a/libensemble/tests/functionality_tests/test_1d_super_simple.py b/libensemble/tests/functionality_tests/test_1d_super_simple.py index 1a178c2cf..657acd4ce 100644 --- a/libensemble/tests/functionality_tests/test_1d_super_simple.py +++ b/libensemble/tests/functionality_tests/test_1d_super_simple.py @@ -14,6 +14,7 @@ # TESTSUITE_NPROCS: 2 4 import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.gen_funcs.sampling import latin_hypercube_sample as gen_f @@ -38,14 +39,13 @@ def sim_f(In): "out": [("f", float)], } + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "out": [("x", float, (1,))], "batch_size": 500, - "user": { - "lb": np.array([-3]), - "ub": np.array([3]), - }, + "vocs": vocs, } exit_criteria = {"gen_max": 501} diff --git a/libensemble/tests/functionality_tests/test_asktell_sampling.py b/libensemble/tests/functionality_tests/test_asktell_sampling.py index f2b48547a..e4848ffa6 100644 --- a/libensemble/tests/functionality_tests/test_asktell_sampling.py +++ b/libensemble/tests/functionality_tests/test_asktell_sampling.py @@ -45,10 +45,6 @@ def sim_f(In): "out": [("x", float, (2,))], "initial_batch_size": 20, "batch_size": 10, - "user": { - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, } variables = {"x0": [-3, 3], "x1": [-2, 2]} diff --git a/libensemble/tests/functionality_tests/test_calc_exception.py b/libensemble/tests/functionality_tests/test_calc_exception.py index 55d99ca63..bc5f3bc4b 100644 --- a/libensemble/tests/functionality_tests/test_calc_exception.py +++ b/libensemble/tests/functionality_tests/test_calc_exception.py @@ -11,7 +11,7 @@ # TESTSUITE_COMMS: mpi local tcp # TESTSUITE_NPROCS: 2 4 -import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.gen_funcs.sampling import uniform_random_sample as gen_f @@ -35,15 +35,14 @@ def six_hump_camel_err(H, persis_info, sim_specs, _): "out": [("f", float)], } + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "in": ["sim_id"], "out": [("x", float, 2)], "batch_size": 10, - "user": { - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, + "vocs": vocs, } alloc_specs = { diff --git a/libensemble/tests/functionality_tests/test_cancel_in_alloc.py b/libensemble/tests/functionality_tests/test_cancel_in_alloc.py index f0bcead55..3916395e4 100644 --- a/libensemble/tests/functionality_tests/test_cancel_in_alloc.py +++ b/libensemble/tests/functionality_tests/test_cancel_in_alloc.py @@ -18,6 +18,7 @@ # TESTSUITE_NPROCS: 4 import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.gen_funcs.sampling import uniform_random_sample as gen_f @@ -39,16 +40,15 @@ "user": {"uniform_random_pause_ub": 10}, } + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "in": ["sim_id"], "out": [("x", float, (2,))], "batch_size": 5, "num_active_gens": 1, - "user": { - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, + "vocs": vocs, } alloc_specs = { @@ -62,7 +62,13 @@ exit_criteria = {"sim_max": 10, "wallclock_max": 300} # Perform the run - H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria, libE_specs=libE_specs, alloc_specs=alloc_specs) + H, persis_info, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + libE_specs=libE_specs, + alloc_specs=alloc_specs, + ) if is_manager: test = np.any(H["cancel_requested"]) and np.any(H["kill_sent"]) diff --git a/libensemble/tests/functionality_tests/test_comms.py b/libensemble/tests/functionality_tests/test_comms.py index daa9e564c..3d1c9a664 100644 --- a/libensemble/tests/functionality_tests/test_comms.py +++ b/libensemble/tests/functionality_tests/test_comms.py @@ -15,6 +15,7 @@ # TESTSUITE_NPROCS: 2 4 import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.executors.mpi_executor import MPIExecutor # Only used to get workerID in float_x1000 @@ -41,15 +42,14 @@ "out": [("arr_vals", float, array_size), ("scal_val", float)], } + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "in": ["sim_id"], "out": [("x", float, (2,))], "batch_size": sim_max, - "user": { - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, + "vocs": vocs, } exit_criteria = {"sim_max": sim_max, "wallclock_max": 300} diff --git a/libensemble/tests/functionality_tests/test_elapsed_time_abort.py b/libensemble/tests/functionality_tests/test_elapsed_time_abort.py index 1396da50f..eb71fc952 100644 --- a/libensemble/tests/functionality_tests/test_elapsed_time_abort.py +++ b/libensemble/tests/functionality_tests/test_elapsed_time_abort.py @@ -13,7 +13,7 @@ # TESTSUITE_COMMS: mpi local tcp # TESTSUITE_NPROCS: 2 4 -import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.gen_funcs.sampling import uniform_random_sample as gen_f @@ -34,16 +34,15 @@ "user": {"pause_time": 2}, } + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "in": ["sim_id"], "out": [("x", float, (2,))], "batch_size": 5, "num_active_gens": 2, - "user": { - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, + "vocs": vocs, } alloc_specs = { diff --git a/libensemble/tests/functionality_tests/test_executor_forces_tutorial.py b/libensemble/tests/functionality_tests/test_executor_forces_tutorial.py index d6b368b93..78393db20 100644 --- a/libensemble/tests/functionality_tests/test_executor_forces_tutorial.py +++ b/libensemble/tests/functionality_tests/test_executor_forces_tutorial.py @@ -1,8 +1,8 @@ import os import sys -import numpy as np from forces_simf import run_forces # Sim func from current dir +from gest_api.vocs import VOCS from libensemble import Ensemble from libensemble.executors import MPIExecutor @@ -37,6 +37,8 @@ outputs=[("energy", float)], ) + vocs = VOCS(variables={"nparticles": [1000, 3000]}, objectives={"energy": "MINIMIZE"}) + ensemble.gen_specs = GenSpecs( gen_f=gen_f, inputs=[], # No input when starting persistent generator @@ -44,10 +46,7 @@ outputs=[("x", float, (1,))], initial_batch_size=nsim_workers, async_return=False, - user={ - "lb": np.array([1000]), # min particles - "ub": np.array([3000]), # max particles - }, + vocs=vocs, ) # gen_specs_end_tag # Starts one persistent generator. Simulated values are returned in batch. diff --git a/libensemble/tests/functionality_tests/test_executor_forces_tutorial_2.py b/libensemble/tests/functionality_tests/test_executor_forces_tutorial_2.py index 2a6cda15b..a19bac4bb 100644 --- a/libensemble/tests/functionality_tests/test_executor_forces_tutorial_2.py +++ b/libensemble/tests/functionality_tests/test_executor_forces_tutorial_2.py @@ -1,8 +1,8 @@ import os import sys -import numpy as np from forces_simf import run_forces # Sim func from current dir +from gest_api.vocs import VOCS from libensemble import Ensemble, logger from libensemble.executors import MPIExecutor @@ -39,6 +39,8 @@ outputs=[("energy", float)], ) + vocs = VOCS(variables={"nparticles": [1000, 3000]}, objectives={"energy": "MINIMIZE"}) + ensemble.gen_specs = GenSpecs( gen_f=gen_f, inputs=[], # No input when starting persistent generator @@ -46,10 +48,7 @@ outputs=[("x", float, (1,))], initial_batch_size=nsim_workers, async_return=True, - user={ - "lb": np.array([1000]), # min particles - "ub": np.array([3000]), # max particles - }, + vocs=vocs, ) # gen_specs_end_tag # Starts one persistent generator. Simulated values are returned in batch. diff --git a/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py b/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py index a65462e0d..96058f748 100644 --- a/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py +++ b/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py @@ -13,6 +13,7 @@ import os import numpy as np +from gest_api.vocs import VOCS import libensemble.sim_funcs.six_hump_camel as six_hump_camel from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first @@ -73,15 +74,14 @@ "user": {"cores": cores_per_task}, } + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "in": ["sim_id"], "out": [("x", float, (2,))], "batch_size": nworkers, - "user": { - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, + "vocs": vocs, } # num sim_ended_count conditions in executor_hworld diff --git a/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py b/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py index 2a604007e..ef9e60618 100644 --- a/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py +++ b/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py @@ -13,6 +13,7 @@ import os import numpy as np +from gest_api.vocs import VOCS import libensemble.sim_funcs.six_hump_camel as six_hump_camel from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first @@ -75,15 +76,14 @@ }, } + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "in": ["sim_id"], "out": [("x", float, (2,))], "batch_size": nworkers, - "user": { - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, + "vocs": vocs, } alloc_specs = { diff --git a/libensemble/tests/functionality_tests/test_mpi_runners_subnode_uneven.py b/libensemble/tests/functionality_tests/test_mpi_runners_subnode_uneven.py index c179028ab..92b60ca58 100644 --- a/libensemble/tests/functionality_tests/test_mpi_runners_subnode_uneven.py +++ b/libensemble/tests/functionality_tests/test_mpi_runners_subnode_uneven.py @@ -11,7 +11,7 @@ import sys -import numpy as np +from gest_api.vocs import VOCS from libensemble import logger from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first @@ -82,15 +82,14 @@ "out": [("f", float)], } + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "in": ["sim_id"], "out": [("x", float, (n,))], "batch_size": 20, - "user": { - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, + "vocs": vocs, } alloc_specs = {"alloc_f": give_sim_work_first} @@ -139,6 +138,12 @@ } # Perform the run - H, _, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, _, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) # All asserts are in sim func diff --git a/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py b/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py index 428297d5d..f4187a670 100644 --- a/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py +++ b/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py @@ -8,7 +8,7 @@ python test_mpi_runners_supernode_uneven.py --nworkers 5 """ -import numpy as np +from gest_api.vocs import VOCS from libensemble import logger from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first @@ -72,15 +72,14 @@ "out": [("f", float)], } + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "in": [], "out": [("x", float, (n,))], "batch_size": 20, - "user": { - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, + "vocs": vocs, } exit_criteria = {"sim_max": (nsim_workers) * rounds} @@ -134,6 +133,12 @@ alloc_specs = {"alloc_f": give_sim_work_first} # Perform the run - H, _, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, _, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) # All asserts are in sim func diff --git a/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_async.py b/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_async.py index 9dde0f620..b841c9165 100644 --- a/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_async.py +++ b/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_async.py @@ -19,6 +19,7 @@ import sys import numpy as np +from gest_api.vocs import VOCS from libensemble.gen_funcs.persistent_sampling import persistent_uniform as gen_f @@ -44,16 +45,15 @@ "user": {"uniform_random_pause_ub": 0.5}, } + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "persis_in": ["f", "x", "sim_id"], "out": [("x", float, (n,))], "initial_batch_size": nworkers, "async_return": True, - "user": { - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, + "vocs": vocs, } exit_criteria = {"gen_max": 100, "wallclock_max": 300} diff --git a/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py b/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py index baa34b838..07fff2e42 100644 --- a/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py +++ b/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py @@ -16,7 +16,7 @@ import os -import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.gen_funcs.sampling import uniform_random_sample as gen_f @@ -52,14 +52,13 @@ "out": [("f", float)], } + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "out": [("x", float, (1,))], "batch_size": 20, - "user": { - "lb": np.array([-3]), - "ub": np.array([3]), - }, + "vocs": vocs, } alloc_specs = { diff --git a/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py b/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py index 74c77252c..f75d847ad 100644 --- a/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py +++ b/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py @@ -16,7 +16,7 @@ import os -import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.gen_funcs.sampling import uniform_random_sample as gen_f @@ -51,14 +51,13 @@ "out": [("f", float)], } + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "out": [("x", float, (1,))], "batch_size": 20, - "user": { - "lb": np.array([-3]), - "ub": np.array([3]), - }, + "vocs": vocs, } alloc_specs = {"alloc_f": give_sim_work_first} diff --git a/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py b/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py index b3189dc1a..697f4dab5 100644 --- a/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py +++ b/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py @@ -16,7 +16,7 @@ import os -import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.gen_funcs.sampling import uniform_random_sample as gen_f @@ -44,14 +44,13 @@ "out": [("f", float)], } + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "out": [("x", float, (1,))], "batch_size": 20, - "user": { - "lb": np.array([-3]), - "ub": np.array([3]), - }, + "vocs": vocs, } alloc_specs = { diff --git a/libensemble/tests/functionality_tests/test_sim_input_dir_option.py b/libensemble/tests/functionality_tests/test_sim_input_dir_option.py index 4e58d27d6..b7b2c64f2 100644 --- a/libensemble/tests/functionality_tests/test_sim_input_dir_option.py +++ b/libensemble/tests/functionality_tests/test_sim_input_dir_option.py @@ -16,7 +16,7 @@ import os -import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.gen_funcs.sampling import uniform_random_sample as gen_f @@ -47,14 +47,13 @@ "out": [("f", float)], } + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "out": [("x", float, (1,))], "batch_size": 20, - "user": { - "lb": np.array([-3]), - "ub": np.array([3]), - }, + "vocs": vocs, } exit_criteria = {"sim_max": 21} diff --git a/libensemble/tests/functionality_tests/test_uniform_sampling.py b/libensemble/tests/functionality_tests/test_uniform_sampling.py index 4d8a6baa7..1c7005bee 100644 --- a/libensemble/tests/functionality_tests/test_uniform_sampling.py +++ b/libensemble/tests/functionality_tests/test_uniform_sampling.py @@ -18,6 +18,7 @@ import os import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.gen_funcs.sampling import uniform_random_sample @@ -45,14 +46,13 @@ } # end_sim_specs_rst_tag + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": uniform_random_sample, # Function generating sim_f input "out": [("x", float, (2,))], # Tell libE gen_f output, type, size "batch_size": 500, - "user": { - "lb": np.array([-3, -2]), # Used by this specific gen_f - "ub": np.array([3, 2]), # Used by this specific gen_f - }, + "vocs": vocs, } # end_gen_specs_rst_tag diff --git a/libensemble/tests/functionality_tests/test_worker_exceptions.py b/libensemble/tests/functionality_tests/test_worker_exceptions.py index efdba0ec4..296d7ead3 100644 --- a/libensemble/tests/functionality_tests/test_worker_exceptions.py +++ b/libensemble/tests/functionality_tests/test_worker_exceptions.py @@ -14,7 +14,7 @@ # TESTSUITE_COMMS: mpi local tcp # TESTSUITE_NPROCS: 2 4 -import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.gen_funcs.sampling import uniform_random_sample as gen_f @@ -34,15 +34,14 @@ "out": [("f", float)], } + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "in": [], "out": [("x", float, 2)], - "user": { - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - "initial_sample": 100, - }, + "vocs": vocs, + "user": {"initial_sample": 100}, } libE_specs["abort_on_exception"] = False diff --git a/libensemble/tests/functionality_tests/test_workflow_dir.py b/libensemble/tests/functionality_tests/test_workflow_dir.py index 6502b78ed..d5ec5fc6c 100644 --- a/libensemble/tests/functionality_tests/test_workflow_dir.py +++ b/libensemble/tests/functionality_tests/test_workflow_dir.py @@ -16,7 +16,7 @@ import os -import numpy as np +from gest_api.vocs import VOCS from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first from libensemble.gen_funcs.sampling import uniform_random_sample as gen_f @@ -49,14 +49,13 @@ "out": [("f", float)], } + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + gen_specs = { "gen_f": gen_f, "out": [("x", float, (1,))], "batch_size": 20, - "user": { - "lb": np.array([-3]), - "ub": np.array([3]), - }, + "vocs": vocs, } alloc_specs = { diff --git a/libensemble/tests/regression_tests/test_xopt_EI_initial_sample.py b/libensemble/tests/regression_tests/test_xopt_EI_initial_sample.py index d89de9449..116ffeb13 100644 --- a/libensemble/tests/regression_tests/test_xopt_EI_initial_sample.py +++ b/libensemble/tests/regression_tests/test_xopt_EI_initial_sample.py @@ -39,10 +39,9 @@ def xtest_sim(H, persis_info, sim_specs, _): if __name__ == "__main__": - batch_size = 4 - libE_specs = LibeSpecs(gen_on_manager=True, nworkers=batch_size) + libE_specs = LibeSpecs(nworkers=batch_size) libE_specs.reuse_output_dir = True vocs = VOCS( diff --git a/libensemble/tests/regression_tests/test_xopt_EI_initial_sample_instance.py b/libensemble/tests/regression_tests/test_xopt_EI_initial_sample_instance.py index c7db6d363..28a66b076 100644 --- a/libensemble/tests/regression_tests/test_xopt_EI_initial_sample_instance.py +++ b/libensemble/tests/regression_tests/test_xopt_EI_initial_sample_instance.py @@ -41,10 +41,9 @@ def xtest_sim(H, persis_info, sim_specs, _): if __name__ == "__main__": - batch_size = 4 - libE_specs = LibeSpecs(gen_on_manager=True, nworkers=batch_size) + libE_specs = LibeSpecs(nworkers=batch_size) libE_specs.reuse_output_dir = True vocs = VOCS( From ac61de283212fa2cf418f3efcd17b6e4150f33f2 Mon Sep 17 00:00:00 2001 From: jlnav Date: Wed, 27 May 2026 14:03:00 -0500 Subject: [PATCH 03/18] adjusting more tests --- .../test_evaluate_existing_plus_gen.py | 28 +++++++++---------- .../functionality_tests/test_mpi_warning.py | 9 +++--- .../regression_tests/test_1d_sampling.py | 9 +++--- .../regression_tests/test_2d_sampling.py | 8 +++--- 4 files changed, 25 insertions(+), 29 deletions(-) diff --git a/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py b/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py index 3e37bc86d..6d679c9f2 100644 --- a/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py +++ b/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py @@ -15,6 +15,7 @@ # TESTSUITE_NPROCS: 2 4 import numpy as np +from gest_api.vocs import VOCS # Import libEnsemble items for this test from libensemble import Ensemble @@ -24,11 +25,8 @@ from libensemble.specs import AllocSpecs, ExitCriteria, GenSpecs, SimSpecs -def create_H0(gen_specs, H0_size): +def create_H0(lb, ub, H0_size): """Create an H0 for give_pregenerated_sim_work""" - # Manually creating H0 - ub = gen_specs["user"]["ub"] - lb = gen_specs["user"]["lb"] n = len(lb) b = H0_size @@ -45,18 +43,18 @@ def create_H0(gen_specs, H0_size): sampling = Ensemble(parse_args=True) sampling.sim_specs = SimSpecs(sim_f=sim_f, inputs=["x"], out=[("f", float)]) - gen_specs = { - "gen_f": gen_f, - "outputs": [("x", float, (2,))], - "batch_size": 50, - "user": { - "lb": np.array([-3, -3]), - "ub": np.array([3, 3]), - }, - } - sampling.gen_specs = GenSpecs(**gen_specs) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-3, 3]}, objectives={"f": "MINIMIZE"}) + lb = np.array([-3, -3]) + ub = np.array([3, 3]) + + sampling.gen_specs = GenSpecs( + gen_f=gen_f, + outputs=[("x", float, (2,))], + batch_size=50, + vocs=vocs, + ) sampling.exit_criteria = ExitCriteria(sim_max=100) - sampling.H0 = create_H0(gen_specs, 50) + sampling.H0 = create_H0(lb, ub, 50) sampling.alloc_specs = AllocSpecs(alloc_f=give_sim_work_first) sampling.run() diff --git a/libensemble/tests/functionality_tests/test_mpi_warning.py b/libensemble/tests/functionality_tests/test_mpi_warning.py index f58620b90..c2eafca37 100644 --- a/libensemble/tests/functionality_tests/test_mpi_warning.py +++ b/libensemble/tests/functionality_tests/test_mpi_warning.py @@ -14,7 +14,7 @@ import os import time -import numpy as np +from gest_api.vocs import VOCS from libensemble import Ensemble, logger from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first @@ -33,14 +33,13 @@ sampling = Ensemble() sampling.libE_specs.save_every_k_sims = 100 sampling.sim_specs = SimSpecs(sim_f=sim_f, inputs=["x"], outputs=[("f", float)]) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + sampling.gen_specs = GenSpecs( gen_f=gen_f, outputs=[("x", float, 2)], batch_size=100, - user={ - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, + vocs=vocs, ) sampling.alloc_specs = AllocSpecs(alloc_f=give_sim_work_first) diff --git a/libensemble/tests/regression_tests/test_1d_sampling.py b/libensemble/tests/regression_tests/test_1d_sampling.py index 456b3ca60..009518e63 100644 --- a/libensemble/tests/regression_tests/test_1d_sampling.py +++ b/libensemble/tests/regression_tests/test_1d_sampling.py @@ -13,7 +13,7 @@ # TESTSUITE_COMMS: mpi local threads tcp # TESTSUITE_NPROCS: 3 4 -import numpy as np +from gest_api.vocs import VOCS from libensemble import Ensemble from libensemble.gen_funcs.persistent_sampling import persistent_uniform @@ -26,15 +26,14 @@ sampling = Ensemble(parse_args=True) sampling.libE_specs = LibeSpecs(save_every_k_gens=300, safe_mode=False, disable_log_files=True) sampling.sim_specs = SimSpecs(sim_f=sim_f, inputs=["x"], outputs=[("f", float)]) + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + sampling.gen_specs = GenSpecs( gen_f=persistent_uniform, persis_in=["f"], outputs=[("x", float, (1,))], initial_batch_size=100, - user={ - "lb": np.array([-3]), - "ub": np.array([3]), - }, + vocs=vocs, ) sampling.exit_criteria = ExitCriteria(sim_max=500) diff --git a/libensemble/tests/regression_tests/test_2d_sampling.py b/libensemble/tests/regression_tests/test_2d_sampling.py index c26a66201..27862952c 100644 --- a/libensemble/tests/regression_tests/test_2d_sampling.py +++ b/libensemble/tests/regression_tests/test_2d_sampling.py @@ -14,6 +14,7 @@ # TESTSUITE_NPROCS: 2 4 import numpy as np +from gest_api.vocs import VOCS from libensemble import Ensemble from libensemble.alloc_funcs.give_sim_work_first import give_sim_work_first @@ -28,14 +29,13 @@ sampling = Ensemble(parse_args=True) sampling.libE_specs = LibeSpecs(save_every_k_sims=100) sampling.sim_specs = SimSpecs(sim_f=sim_f, inputs=["x"], outputs=[("f", float)]) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + sampling.gen_specs = GenSpecs( gen_f=gen_f, outputs=[("x", float, 2)], batch_size=100, - user={ - "lb": np.array([-3, -2]), - "ub": np.array([3, 2]), - }, + vocs=vocs, ) sampling.alloc_specs = AllocSpecs(alloc_f=give_sim_work_first) From 3e39ff3bc68f4bf69f788d01775f590e79e4af5c Mon Sep 17 00:00:00 2001 From: jlnav Date: Wed, 27 May 2026 14:23:41 -0500 Subject: [PATCH 04/18] fix test --- .../tests/functionality_tests/test_evaluate_existing_plus_gen.py | 1 + 1 file changed, 1 insertion(+) diff --git a/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py b/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py index 6d679c9f2..5da225427 100644 --- a/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py +++ b/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py @@ -49,6 +49,7 @@ def create_H0(lb, ub, H0_size): sampling.gen_specs = GenSpecs( gen_f=gen_f, + persis_in=["f"], outputs=[("x", float, (2,))], batch_size=50, vocs=vocs, From 026e6b337435384c070f7bf5a0d2b5153c940275 Mon Sep 17 00:00:00 2001 From: jlnav Date: Thu, 28 May 2026 15:08:37 -0500 Subject: [PATCH 05/18] coverage --- libensemble/tests/unit_tests/test_models.py | 34 +++++++++++++++++++++ 1 file changed, 34 insertions(+) diff --git a/libensemble/tests/unit_tests/test_models.py b/libensemble/tests/unit_tests/test_models.py index fa6e2c1f9..01341ed94 100644 --- a/libensemble/tests/unit_tests/test_models.py +++ b/libensemble/tests/unit_tests/test_models.py @@ -174,6 +174,38 @@ def test_vocs_to_gen_specs(): assert gs2.persis_in == ["custom"] and gs2.outputs == [("custom_out", int)] +class _VariableWithDomain: + def __init__(self, domain): + self.domain = domain + + +class _VocsWithVariableDomains: + variables = {"x0": _VariableWithDomain([-3, 3]), "x1": _VariableWithDomain([-2, 2])} + constants = {} + objectives = {} + observables = {} + constraints = {} + + +def test_gen_specs_sets_user_bounds_from_vocs_variable_domains(): + """GenSpecs should populate user bounds from VOCS variable domain attributes.""" + + gs = GenSpecs(vocs=_VocsWithVariableDomains(), user=None) + + assert np.array_equal(gs.user["lb"], np.array([-3.0, -2.0])) + assert np.array_equal(gs.user["ub"], np.array([3.0, 2.0])) + + +def test_gen_specs_preserves_partial_user_bounds_from_vocs_variable_domains(): + """GenSpecs should only fill missing bounds from VOCS variable domain attributes.""" + + explicit_lb = np.array([0.0, 0.0]) + gs = GenSpecs(vocs=_VocsWithVariableDomains(), user={"lb": explicit_lb}) + + assert np.array_equal(gs.user["lb"], explicit_lb) + assert np.array_equal(gs.user["ub"], np.array([3.0, 2.0])) + + if __name__ == "__main__": test_sim_gen_alloc_exit_specs() test_sim_gen_alloc_exit_specs_invalid() @@ -182,3 +214,5 @@ def test_vocs_to_gen_specs(): test_ensemble_specs() test_vocs_to_sim_specs() test_vocs_to_gen_specs() + test_gen_specs_sets_user_bounds_from_vocs_variable_domains() + test_gen_specs_preserves_partial_user_bounds_from_vocs_variable_domains() From 1f03f1a3763856430ffa424912cd25b9001d62ca Mon Sep 17 00:00:00 2001 From: jlnav Date: Fri, 29 May 2026 08:36:17 -0500 Subject: [PATCH 06/18] "f": "MINIMIZE" in many tests wasn't correct --- .../test_1d_super_simple.py | 10 ++++++-- .../test_calc_exception.py | 10 ++++++-- .../test_cancel_in_alloc.py | 2 +- .../tests/functionality_tests/test_comms.py | 10 ++++++-- .../test_elapsed_time_abort.py | 10 ++++++-- .../test_evaluate_existing_plus_gen.py | 3 +-- .../test_executor_hworld_pass_fail.py | 25 ++++++++++++++++--- .../test_executor_hworld_timeout.py | 10 ++++++-- .../test_mpi_runners_subnode_uneven.py | 2 +- .../test_mpi_runners_supernode_uneven.py | 2 +- .../functionality_tests/test_mpi_warning.py | 2 +- .../test_persistent_uniform_sampling_async.py | 2 +- .../test_sim_dirs_per_calc.py | 10 ++++++-- .../test_sim_dirs_per_worker.py | 10 ++++++-- .../test_sim_dirs_with_exception.py | 10 ++++++-- .../test_sim_input_dir_option.py | 10 ++++++-- .../test_uniform_sampling.py | 10 ++++++-- .../test_worker_exceptions.py | 10 ++++++-- .../functionality_tests/test_workflow_dir.py | 10 ++++++-- .../regression_tests/test_1d_sampling.py | 2 +- .../regression_tests/test_2d_sampling.py | 2 +- .../regression_tests/test_2d_sampling_vocs.py | 4 +-- libensemble/tests/unit_tests/test_ensemble.py | 18 ++++++++----- 23 files changed, 139 insertions(+), 45 deletions(-) diff --git a/libensemble/tests/functionality_tests/test_1d_super_simple.py b/libensemble/tests/functionality_tests/test_1d_super_simple.py index 657acd4ce..ff167ae3d 100644 --- a/libensemble/tests/functionality_tests/test_1d_super_simple.py +++ b/libensemble/tests/functionality_tests/test_1d_super_simple.py @@ -39,7 +39,7 @@ def sim_f(In): "out": [("f", float)], } - vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -54,7 +54,13 @@ def sim_f(In): "alloc_f": give_sim_work_first, } - H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, persis_info, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) if is_manager: assert len(H) >= 501 diff --git a/libensemble/tests/functionality_tests/test_calc_exception.py b/libensemble/tests/functionality_tests/test_calc_exception.py index bc5f3bc4b..81c0b791a 100644 --- a/libensemble/tests/functionality_tests/test_calc_exception.py +++ b/libensemble/tests/functionality_tests/test_calc_exception.py @@ -35,7 +35,7 @@ def six_hump_camel_err(H, persis_info, sim_specs, _): "out": [("f", float)], } - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -56,7 +56,13 @@ def six_hump_camel_err(H, persis_info, sim_specs, _): # Perform the run return_flag = 1 try: - H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, persis_info, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) except LoggedException as e: print(f"Caught deliberate exception: {e}") return_flag = 0 diff --git a/libensemble/tests/functionality_tests/test_cancel_in_alloc.py b/libensemble/tests/functionality_tests/test_cancel_in_alloc.py index ba5fad09b..72953cdfd 100644 --- a/libensemble/tests/functionality_tests/test_cancel_in_alloc.py +++ b/libensemble/tests/functionality_tests/test_cancel_in_alloc.py @@ -40,7 +40,7 @@ "user": {"uniform_random_pause_ub": 10}, # long sleep ensures sims are still running when cancel fires } - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, diff --git a/libensemble/tests/functionality_tests/test_comms.py b/libensemble/tests/functionality_tests/test_comms.py index 3d1c9a664..de46c20f8 100644 --- a/libensemble/tests/functionality_tests/test_comms.py +++ b/libensemble/tests/functionality_tests/test_comms.py @@ -42,7 +42,7 @@ "out": [("arr_vals", float, array_size), ("scal_val", float)], } - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -59,7 +59,13 @@ } # Perform the run - H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, persis_info, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) if is_manager: assert flag == 0 diff --git a/libensemble/tests/functionality_tests/test_elapsed_time_abort.py b/libensemble/tests/functionality_tests/test_elapsed_time_abort.py index eb71fc952..419f85c98 100644 --- a/libensemble/tests/functionality_tests/test_elapsed_time_abort.py +++ b/libensemble/tests/functionality_tests/test_elapsed_time_abort.py @@ -34,7 +34,7 @@ "user": {"pause_time": 2}, } - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -55,7 +55,13 @@ exit_criteria = {"wallclock_max": 1} # Perform the run - H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria, libE_specs=libE_specs, alloc_specs=alloc_specs) + H, persis_info, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + libE_specs=libE_specs, + alloc_specs=alloc_specs, + ) if is_manager: eprint(flag) diff --git a/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py b/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py index 5da225427..bfe30eae9 100644 --- a/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py +++ b/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py @@ -39,11 +39,10 @@ def create_H0(lb, ub, H0_size): # Main block is necessary only when using local comms with spawn start method (default on macOS and Windows). if __name__ == "__main__": - sampling = Ensemble(parse_args=True) sampling.sim_specs = SimSpecs(sim_f=sim_f, inputs=["x"], out=[("f", float)]) - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-3, 3]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-3, 3]}, objectives={"f": "EXPLORE"}) lb = np.array([-3, -3]) ub = np.array([3, 3]) diff --git a/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py b/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py index 96058f748..c138ea846 100644 --- a/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py +++ b/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py @@ -22,7 +22,12 @@ from libensemble.libE import libE # Import libEnsemble items for this test -from libensemble.message_numbers import TASK_FAILED, WORKER_DONE, WORKER_KILL_ON_ERR, WORKER_KILL_ON_TIMEOUT +from libensemble.message_numbers import ( + TASK_FAILED, + WORKER_DONE, + WORKER_KILL_ON_ERR, + WORKER_KILL_ON_TIMEOUT, +) from libensemble.sim_funcs.executor_hworld import executor_hworld as sim_f from libensemble.tests.regression_tests.common import build_simfunc from libensemble.tools import parse_args @@ -74,7 +79,7 @@ "user": {"cores": cores_per_task}, } - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -92,13 +97,25 @@ } # Perform the run - H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, persis_info, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) if is_manager: print("\nChecking expected task status against Workers ...\n") calc_status_list_in = np.asarray( - [WORKER_DONE, WORKER_KILL_ON_ERR, WORKER_DONE, WORKER_KILL_ON_TIMEOUT, TASK_FAILED] + [ + WORKER_DONE, + WORKER_KILL_ON_ERR, + WORKER_DONE, + WORKER_KILL_ON_TIMEOUT, + TASK_FAILED, + ] ) calc_status_list = np.repeat(calc_status_list_in, nworkers) diff --git a/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py b/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py index ef9e60618..edab131de 100644 --- a/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py +++ b/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py @@ -76,7 +76,7 @@ }, } - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -100,7 +100,13 @@ for i in range(iterations): # Perform the run - H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, persis_info, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) if is_manager: print("\nChecking expected task status against Workers ...\n") diff --git a/libensemble/tests/functionality_tests/test_mpi_runners_subnode_uneven.py b/libensemble/tests/functionality_tests/test_mpi_runners_subnode_uneven.py index 92b60ca58..643a41563 100644 --- a/libensemble/tests/functionality_tests/test_mpi_runners_subnode_uneven.py +++ b/libensemble/tests/functionality_tests/test_mpi_runners_subnode_uneven.py @@ -82,7 +82,7 @@ "out": [("f", float)], } - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, diff --git a/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py b/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py index f4187a670..be826d91e 100644 --- a/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py +++ b/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py @@ -72,7 +72,7 @@ "out": [("f", float)], } - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, diff --git a/libensemble/tests/functionality_tests/test_mpi_warning.py b/libensemble/tests/functionality_tests/test_mpi_warning.py index c2eafca37..257297a6c 100644 --- a/libensemble/tests/functionality_tests/test_mpi_warning.py +++ b/libensemble/tests/functionality_tests/test_mpi_warning.py @@ -33,7 +33,7 @@ sampling = Ensemble() sampling.libE_specs.save_every_k_sims = 100 sampling.sim_specs = SimSpecs(sim_f=sim_f, inputs=["x"], outputs=[("f", float)]) - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) sampling.gen_specs = GenSpecs( gen_f=gen_f, diff --git a/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_async.py b/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_async.py index 09ee58a7f..4b36678bc 100644 --- a/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_async.py +++ b/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_async.py @@ -45,7 +45,7 @@ "user": {"uniform_random_pause_ub": 0.5}, } - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, diff --git a/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py b/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py index 07fff2e42..7e49ab997 100644 --- a/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py +++ b/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py @@ -52,7 +52,7 @@ "out": [("f", float)], } - vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -67,7 +67,13 @@ exit_criteria = {"sim_max": 21} - H, _, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, _, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) if is_manager: assert os.path.isdir(c_ensemble), f"Ensemble directory {c_ensemble} not created." diff --git a/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py b/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py index f75d847ad..3d041b237 100644 --- a/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py +++ b/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py @@ -51,7 +51,7 @@ "out": [("f", float)], } - vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -64,7 +64,13 @@ exit_criteria = {"sim_max": 21} - H, _, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, _, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) if is_manager: assert os.path.isdir(w_ensemble), f"Ensemble directory {w_ensemble} not created." diff --git a/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py b/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py index 697f4dab5..9fc0a3a17 100644 --- a/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py +++ b/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py @@ -44,7 +44,7 @@ "out": [("f", float)], } - vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -61,7 +61,13 @@ return_flag = 1 try: - H, _, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, _, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) except LoggedException as e: print(f"Caught deliberate exception: {e}") return_flag = 0 diff --git a/libensemble/tests/functionality_tests/test_sim_input_dir_option.py b/libensemble/tests/functionality_tests/test_sim_input_dir_option.py index b7b2c64f2..b3453428f 100644 --- a/libensemble/tests/functionality_tests/test_sim_input_dir_option.py +++ b/libensemble/tests/functionality_tests/test_sim_input_dir_option.py @@ -47,7 +47,7 @@ "out": [("f", float)], } - vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -62,7 +62,13 @@ "alloc_f": give_sim_work_first, } - H, _, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, _, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) if is_manager: assert os.path.isdir(o_ensemble), f"Ensemble directory {o_ensemble} not created." diff --git a/libensemble/tests/functionality_tests/test_uniform_sampling.py b/libensemble/tests/functionality_tests/test_uniform_sampling.py index 1c7005bee..a2da65af9 100644 --- a/libensemble/tests/functionality_tests/test_uniform_sampling.py +++ b/libensemble/tests/functionality_tests/test_uniform_sampling.py @@ -46,7 +46,7 @@ } # end_sim_specs_rst_tag - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": uniform_random_sample, # Function generating sim_f input @@ -70,7 +70,13 @@ sim_specs["user"] = {"history_file": hfile} # Perform the run - H, _, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, _, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) if is_manager: assert flag == 0 diff --git a/libensemble/tests/functionality_tests/test_worker_exceptions.py b/libensemble/tests/functionality_tests/test_worker_exceptions.py index 296d7ead3..ddfec68be 100644 --- a/libensemble/tests/functionality_tests/test_worker_exceptions.py +++ b/libensemble/tests/functionality_tests/test_worker_exceptions.py @@ -34,7 +34,7 @@ "out": [("f", float)], } - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -57,7 +57,13 @@ # Perform the run return_flag = 1 try: - H, _, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, _, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) except LoggedException as e: print(f"Caught deliberate exception: {e}") return_flag = 0 diff --git a/libensemble/tests/functionality_tests/test_workflow_dir.py b/libensemble/tests/functionality_tests/test_workflow_dir.py index d5ec5fc6c..a16796b72 100644 --- a/libensemble/tests/functionality_tests/test_workflow_dir.py +++ b/libensemble/tests/functionality_tests/test_workflow_dir.py @@ -49,7 +49,7 @@ "out": [("f", float)], } - vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "EXPLORE"}) gen_specs = { "gen_f": gen_f, @@ -72,7 +72,13 @@ "./test_workflow" + str(i) + "_nworkers" + str(nworkers) + "_comms-" + libE_specs["comms"] ) - H, _, flag = libE(sim_specs, gen_specs, exit_criteria, alloc_specs=alloc_specs, libE_specs=libE_specs) + H, _, flag = libE( + sim_specs, + gen_specs, + exit_criteria, + alloc_specs=alloc_specs, + libE_specs=libE_specs, + ) assert os.path.isdir(libE_specs["workflow_dir_path"]), "workflow_dir not created" assert all( diff --git a/libensemble/tests/regression_tests/test_1d_sampling.py b/libensemble/tests/regression_tests/test_1d_sampling.py index 009518e63..21d3aaced 100644 --- a/libensemble/tests/regression_tests/test_1d_sampling.py +++ b/libensemble/tests/regression_tests/test_1d_sampling.py @@ -26,7 +26,7 @@ sampling = Ensemble(parse_args=True) sampling.libE_specs = LibeSpecs(save_every_k_gens=300, safe_mode=False, disable_log_files=True) sampling.sim_specs = SimSpecs(sim_f=sim_f, inputs=["x"], outputs=[("f", float)]) - vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3]}, objectives={"f": "EXPLORE"}) sampling.gen_specs = GenSpecs( gen_f=persistent_uniform, diff --git a/libensemble/tests/regression_tests/test_2d_sampling.py b/libensemble/tests/regression_tests/test_2d_sampling.py index 27862952c..b6b237eae 100644 --- a/libensemble/tests/regression_tests/test_2d_sampling.py +++ b/libensemble/tests/regression_tests/test_2d_sampling.py @@ -29,7 +29,7 @@ sampling = Ensemble(parse_args=True) sampling.libE_specs = LibeSpecs(save_every_k_sims=100) sampling.sim_specs = SimSpecs(sim_f=sim_f, inputs=["x"], outputs=[("f", float)]) - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) sampling.gen_specs = GenSpecs( gen_f=gen_f, diff --git a/libensemble/tests/regression_tests/test_2d_sampling_vocs.py b/libensemble/tests/regression_tests/test_2d_sampling_vocs.py index f535e1709..94740420a 100644 --- a/libensemble/tests/regression_tests/test_2d_sampling_vocs.py +++ b/libensemble/tests/regression_tests/test_2d_sampling_vocs.py @@ -33,7 +33,7 @@ def sim_f(In, persis_info, sim_specs, _): vocs = VOCS( variables={"x0": [-3.0, 3.0], "x1": [-2.0, 2.0]}, - objectives={"f": "MINIMIZE"}, + objectives={"f": "EXPLORE"}, ) generator = LatinHypercubeSample(vocs, random_seed=1) @@ -53,6 +53,6 @@ def sim_f(In, persis_info, sim_specs, _): x0 = sampling.H["x0"] x1 = sampling.H["x1"] f = sampling.H["f"] - assert np.all(np.isclose(f, np.sqrt(x0 ** 2 + x1 ** 2))) + assert np.all(np.isclose(f, np.sqrt(x0**2 + x1**2))) print("\nlibEnsemble has calculated the 2D vector norm of all points") sampling.save_output(__file__) diff --git a/libensemble/tests/unit_tests/test_ensemble.py b/libensemble/tests/unit_tests/test_ensemble.py index 3ea2abccb..5e5e9314f 100644 --- a/libensemble/tests/unit_tests/test_ensemble.py +++ b/libensemble/tests/unit_tests/test_ensemble.py @@ -42,7 +42,13 @@ def test_full_workflow(): from libensemble.ensemble import Ensemble from libensemble.gen_funcs.sampling import latin_hypercube_sample from libensemble.sim_funcs.simple_sim import norm_eval - from libensemble.specs import AllocSpecs, ExitCriteria, GenSpecs, LibeSpecs, SimSpecs + from libensemble.specs import ( + AllocSpecs, + ExitCriteria, + GenSpecs, + LibeSpecs, + SimSpecs, + ) LS = LibeSpecs(comms="local", nworkers=4) @@ -278,7 +284,7 @@ def test_gen_specs_vocs_populates_user_bounds(): vocs = VOCS( variables={"x0": [-3, 3], "x1": [-2, 2], "x2": [-1, 1], "x3": [-1, 1]}, - objectives={"f": "MINIMIZE"}, + objectives={"f": "EXPLORE"}, ) gs = GenSpecs(vocs=vocs) assert "lb" in gs.user, "lb should be populated in user from VOCS" @@ -295,7 +301,7 @@ def test_gen_specs_vocs_does_not_overwrite_user_bounds(): from libensemble.specs import GenSpecs - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) explicit_lb = np.array([0.0, 0.0]) explicit_ub = np.array([1.0, 1.0]) gs = GenSpecs(vocs=vocs, user={"lb": explicit_lb, "ub": explicit_ub}) @@ -309,7 +315,7 @@ def test_gen_specs_vocs_partial_user_bounds(): from libensemble.specs import GenSpecs - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) explicit_lb = np.array([0.0, 0.0]) gs = GenSpecs(vocs=vocs, user={"lb": explicit_lb}) assert np.array_equal(gs.user["lb"], explicit_lb), "Explicit lb should be preserved" @@ -335,7 +341,7 @@ def test_gen_specs_vocs_satisfies_legacy_user_params(): from libensemble.gen_funcs.persistent_sampling import _get_user_params from libensemble.specs import GenSpecs - vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [-3, 3], "x1": [-2, 2]}, objectives={"f": "EXPLORE"}) gs = GenSpecs(vocs=vocs, initial_batch_size=10) # Convert to dict shape that _get_user_params expects @@ -355,7 +361,7 @@ def test_gen_specs_vocs_integer_domain_yields_float_array(): from libensemble.specs import GenSpecs - vocs = VOCS(variables={"x0": [0, 10], "x1": [-5, 5]}, objectives={"f": "MINIMIZE"}) + vocs = VOCS(variables={"x0": [0, 10], "x1": [-5, 5]}, objectives={"f": "EXPLORE"}) gs = GenSpecs(vocs=vocs) assert gs.user["lb"].dtype == float, "lb should be float dtype even for integer-domain variables" assert gs.user["ub"].dtype == float, "ub should be float dtype even for integer-domain variables" From 883a05264d42ceff884779607f4a1505189df79c Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 19:52:44 +0000 Subject: [PATCH 07/18] Bump crate-ci/typos from 1.47.2 to 1.48.0 Bumps [crate-ci/typos](https://github.com/crate-ci/typos) from 1.47.2 to 1.48.0. - [Release notes](https://github.com/crate-ci/typos/releases) - [Changelog](https://github.com/crate-ci/typos/blob/master/CHANGELOG.md) - [Commits](https://github.com/crate-ci/typos/compare/v1.47.2...v1.48.0) --- updated-dependencies: - dependency-name: crate-ci/typos dependency-version: 1.48.0 dependency-type: direct:production update-type: version-update:semver-minor ... Signed-off-by: dependabot[bot] --- .github/workflows/basic.yml | 2 +- .github/workflows/extra.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/basic.yml b/.github/workflows/basic.yml index 3e5de9480..be50e3436 100644 --- a/.github/workflows/basic.yml +++ b/.github/workflows/basic.yml @@ -98,4 +98,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: crate-ci/typos@v1.47.2 + - uses: crate-ci/typos@v1.48.0 diff --git a/.github/workflows/extra.yml b/.github/workflows/extra.yml index e6a2f6e11..1111d2852 100644 --- a/.github/workflows/extra.yml +++ b/.github/workflows/extra.yml @@ -111,4 +111,4 @@ jobs: runs-on: ubuntu-latest steps: - uses: actions/checkout@v7 - - uses: crate-ci/typos@v1.47.2 + - uses: crate-ci/typos@v1.48.0 From 8c476579eb8eed44cbd1bbf9a2ad75153c770160 Mon Sep 17 00:00:00 2001 From: "dependabot[bot]" <49699333+dependabot[bot]@users.noreply.github.com> Date: Mon, 6 Jul 2026 20:03:02 +0000 Subject: [PATCH 08/18] Update setuptools requirement in the python-updates group Updates the requirements on [setuptools](https://github.com/pypa/setuptools) to permit the latest version. Updates `setuptools` to 83.0.0 - [Release notes](https://github.com/pypa/setuptools/releases) - [Changelog](https://github.com/pypa/setuptools/blob/main/NEWS.rst) - [Commits](https://github.com/pypa/setuptools/compare/v75.1.0...v83.0.0) --- updated-dependencies: - dependency-name: setuptools dependency-version: 83.0.0 dependency-type: direct:development dependency-group: python-updates ... Signed-off-by: dependabot[bot] --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index fbb96f22d..5302b3039 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -43,7 +43,7 @@ Issues = "https://github.com/Libensemble/libensemble/issues" [build-system] build-backend = "setuptools.build_meta" -requires = ["setuptools", "wheel", "pip>=24.3.1,<27", "setuptools>=75.1.0,<83"] +requires = ["setuptools", "wheel", "pip>=24.3.1,<27", "setuptools>=75.1.0,<84"] [tool.setuptools.packages.find] where = ["."] From 93b2ac31d9daf36cae1a97dc696b8dc0fca2ae68 Mon Sep 17 00:00:00 2001 From: jlnav Date: Tue, 7 Jul 2026 13:18:25 -0500 Subject: [PATCH 09/18] Add FluxExecutor using native Flux Python API bindings FluxExecutor submits jobs directly to Flux via its Python bindings rather than wrapping flux run as a subprocess. It is useful when running inside containers where standard MPI runners are unavailable. - New libensemble/executors/flux_executor.py with FluxExecutor and FluxTask - Conditionally import FluxExecutor in executors/__init__.py (graceful ImportError if flux-core bindings are not installed) - Append FluxExecutor unit tests to test_flux.py; tests skip automatically when flux-core Python bindings are not present --- libensemble/executors/__init__.py | 9 +- libensemble/executors/flux_executor.py | 466 ++++++++++++++++++++++ libensemble/tests/unit_tests/test_flux.py | 132 ++++++ 3 files changed, 606 insertions(+), 1 deletion(-) create mode 100644 libensemble/executors/flux_executor.py diff --git a/libensemble/executors/__init__.py b/libensemble/executors/__init__.py index 563fa3352..13c7d851d 100644 --- a/libensemble/executors/__init__.py +++ b/libensemble/executors/__init__.py @@ -1,4 +1,11 @@ from libensemble.executors.executor import Executor from libensemble.executors.mpi_executor import MPIExecutor -__all__ = ["Executor", "MPIExecutor"] +# FluxExecutor is optional - requires flux-core Python bindings +try: + from libensemble.executors.flux_executor import FluxExecutor # noqa: F401 + + __all__ = ["Executor", "MPIExecutor", "FluxExecutor"] +except ImportError: + # flux-core not available - FluxExecutor won't be importable + __all__ = ["Executor", "MPIExecutor"] diff --git a/libensemble/executors/flux_executor.py b/libensemble/executors/flux_executor.py new file mode 100644 index 000000000..e2b1aa2e3 --- /dev/null +++ b/libensemble/executors/flux_executor.py @@ -0,0 +1,466 @@ +""" +This module provides a native Flux executor using the Flux Python API. + +The FluxExecutor submits jobs directly to Flux using its Python bindings, +rather than wrapping `flux run` as a subprocess. This provides better +integration with Flux's job lifecycle management and is particularly +useful when running inside containers where MPI runners may not be available. + +Usage:: + + from libensemble.executors.flux_executor import FluxExecutor + + exctr = FluxExecutor() + exctr.register_app(full_path="/path/to/my_app.x", app_name="my_app") + + # In your sim function: + task = exctr.submit(app_name="my_app", num_procs=4, num_nodes=1) + task.wait() + +Requirements: + - flux-core Python bindings must be installed + - Must be running inside a Flux instance (FLUX_URI must be set) +""" + +import logging +import os +import shlex +import time + +from libensemble.executors.executor import ( + Application, + Executor, + ExecutorException, + Task, + jassert, +) + +logger = logging.getLogger(__name__) + +# Try to import flux - it's optional +try: + import flux + import flux.job + from flux.job import JobspecV1 + + FLUX_AVAILABLE = True +except ImportError: + FLUX_AVAILABLE = False + flux = None + JobspecV1 = None + + +class FluxTask(Task): + """ + Task subclass for Flux jobs using native Flux Python API. + + Overrides poll() and kill() to use Flux job management instead + of subprocess operations. + """ + + def __init__( + self, + app=None, + app_args=None, + workdir=None, + stdout=None, + stderr=None, + workerid=None, + dry_run=False, + ) -> None: + super().__init__(app, app_args, workdir, stdout, stderr, workerid, dry_run) + self.flux_handle = None + self.flux_jobid = None + self.flux_future = None + + def reset(self) -> None: + super().reset() + self.flux_jobid = None + self.flux_future = None + + def _check_poll(self) -> bool: + """Check whether polling this task makes sense.""" + jassert( + self.flux_jobid is not None, + f"task {self.name} has no Flux job ID - check task has been launched", + ) + if self.finished: + logger.debug(f"Polled task {self.name} has already finished. Not re-polling. Status is {self.state}") + return False + return True + + def poll(self) -> None: + """Polls and updates the status attributes of the task using Flux job state.""" + if self.dry_run: + self._set_complete() + return + + if not self._check_poll(): + return + + try: + info = flux.job.get_job(self.flux_handle, self.flux_jobid) + jassert(info is not None, f"Flux job {self.flux_jobid} was not found") + state = str(info.get("state", "UNKNOWN")).upper() + + # Map Flux states to libEnsemble states + # Flux states: DEPEND, PRIORITY, SCHED, RUN, CLEANUP, INACTIVE + if state in ("DEPEND", "PRIORITY", "SCHED"): + self.state = "WAITING" + elif state == "RUN": + self.state = "RUNNING" + self.runtime = self.timer.elapsed + elif state in ("CLEANUP", "INACTIVE"): + # Job has finished - check if successful + self._handle_completion(info) + else: + self.state = "UNKNOWN" + self.runtime = self.timer.elapsed + + except Exception as e: + logger.warning(f"Error polling Flux job {self.flux_jobid}: {e}") + self.state = "UNKNOWN" + self.runtime = self.timer.elapsed + + def _handle_completion(self, info: dict) -> None: + """Handle job completion and determine success/failure.""" + self.finished = True + self.calc_task_timing() + + # Check result/exit status + result = str(info.get("result", "")).upper() + success = result == "COMPLETED" or info.get("returncode", 1) == 0 + + if success: + self.success = True + self.state = "FINISHED" + self.errcode = 0 + else: + self.success = False + self.state = "FAILED" + # Try to get exit code from result + self.errcode = info.get("returncode", 1) + + logger.info(f"Task {self.name} finished with state {self.state} (result={result})") + + def _set_complete(self) -> None: + """Set task as complete (used for dry_run).""" + self.finished = True + if self.dry_run: + self.success = True + self.state = "FINISHED" + else: + self.calc_task_timing() + self.success = self.errcode == 0 + self.state = "FINISHED" if self.success else "FAILED" + logger.info(f"Task {self.name} finished with errcode {self.errcode} ({self.state})") + + def wait(self, timeout: float | None = None) -> None: + """Waits on completion of the Flux job or raises TimeoutExpired exception.""" + from libensemble.executors.executor import TimeoutExpired + + if self.dry_run: + self._set_complete() + return + + if not self._check_poll(): + return + + try: + # Wait for job to complete + start_time = time.time() + while True: + self.poll() + if self.finished: + break + + if timeout is not None: + elapsed = time.time() - start_time + if elapsed >= timeout: + raise TimeoutExpired(self.name, timeout) + + time.sleep(0.1) + + except TimeoutExpired: + raise + except Exception as e: + logger.warning(f"Error waiting for Flux job {self.flux_jobid}: {e}") + self.state = "FAILED" + self.finished = True + + def kill(self, wait_time: int | None = 60) -> None: + """Kills/cancels the Flux job. + + Parameters + ---------- + wait_time: int, Optional + Time in seconds to wait for cancellation. + Note: Flux handles job cancellation internally. + """ + self.poll() + if self.dry_run: + return + + if self.finished: + logger.warning(f"Trying to kill task that is no longer running. Task {self.name}: Status is {self.state}") + return + + if self.flux_jobid is None: + logger.warning(f"Task {self.name} has no Flux job ID - cannot kill") + return + + logger.info(f"Canceling Flux job {self.flux_jobid} for task {self.name}") + + try: + # Cancel the job using Flux API + flux.job.cancel(self.flux_handle, self.flux_jobid) + + # Wait briefly for cancellation to take effect + if wait_time: + deadline = time.time() + min(wait_time, 5) # Don't wait too long + while time.time() < deadline: + self.poll() + if self.finished: + break + time.sleep(0.1) + + except Exception as e: + logger.warning(f"Error canceling Flux job {self.flux_jobid}: {e}") + + self.state = "USER_KILLED" + self.finished = True + self.calc_task_timing() + + +class FluxExecutor(Executor): + """ + Native Flux executor using the Flux Python API. + + This executor submits jobs directly to Flux rather than wrapping + `flux run` as a subprocess. It provides better integration with + Flux's job lifecycle and is suitable for container-based workflows. + + Parameters + ---------- + None + + Raises + ------ + ExecutorException + If flux Python bindings are not available or FLUX_URI is not set. + + Example + ------- + :: + + from libensemble.executors.flux_executor import FluxExecutor + + exctr = FluxExecutor() + exctr.register_app(full_path="/path/to/sim.x", app_name="sim") + + # In sim function: + task = exctr.submit(app_name="sim", num_procs=4) + task.wait() + """ + + def __init__(self) -> None: + """Instantiate a new FluxExecutor instance.""" + if not FLUX_AVAILABLE: + raise ExecutorException( + "Flux Python bindings not available. " + "Install flux-core or use MPIExecutor with mpi_runner='flux' instead." + ) + + if not os.environ.get("FLUX_URI"): + raise ExecutorException( + "FLUX_URI environment variable not set. " "FluxExecutor must be used inside a Flux instance." + ) + + super().__init__() + + # Connect to the Flux instance + try: + self.flux_handle = flux.Flux() + except Exception as e: + raise ExecutorException(f"Failed to connect to Flux instance: {e}") + + self.resources = None + self.platform_info: dict = {} + + def set_resources(self, resources) -> None: + """Set resources for the executor.""" + self.resources = resources + + def add_platform_info(self, platform_info: dict | None = None) -> None: + """Add platform info to the executor.""" + self.platform_info = platform_info or {} + + def submit( + self, + calc_type: str | None = None, + app_name: str | None = None, + num_procs: int | None = None, + num_nodes: int | None = None, + procs_per_node: int | None = None, + num_gpus: int | None = None, + app_args: str | None = None, + stdout: str | None = None, + stderr: str | None = None, + dry_run: bool = False, + wait_on_start: bool = False, + extra_args: str | None = None, + ) -> FluxTask: + """Submit a job to Flux. + + Returns :class:`FluxTask` object. + + Parameters + ---------- + calc_type: str, Optional + The calculation type: 'sim' or 'gen' + + app_name: str, Optional + The application name. + + num_procs: int, Optional + The total number of processes (MPI ranks) + + num_nodes: int, Optional + The number of nodes + + procs_per_node: int, Optional + The processes per node + + num_gpus: int, Optional + The total number of GPUs + + app_args: str, Optional + Application arguments + + stdout: str, Optional + Standard output filename + + stderr: str, Optional + Standard error filename + + dry_run: bool, Optional + If True, don't actually submit the job + + wait_on_start: bool, Optional + Whether to wait for job to start running + + extra_args: str, Optional + Additional arguments (currently not used for native Flux) + + Returns + ------- + task: FluxTask + The submitted task object + """ + app: Application | None = None + if app_name is not None: + app = self.get_app(app_name) + elif calc_type is not None: + app = self.default_app(calc_type) + else: + raise ExecutorException("Either app_name or calc_type must be set") + + assert app is not None + + default_workdir = os.getcwd() + task = FluxTask(app, app_args, default_workdir, stdout, stderr, self.workerID, dry_run) + task.flux_handle = self.flux_handle + + if not dry_run: + self._check_app_exists(task.app) + + if extra_args: + raise ExecutorException("extra_args is not supported by FluxExecutor") + + num_procs = num_procs or 1 + if num_nodes is None: + if procs_per_node is not None: + if num_procs % procs_per_node != 0: + raise ExecutorException("num_procs must be divisible by procs_per_node for FluxExecutor") + num_nodes = num_procs // procs_per_node + else: + num_nodes = 1 + elif procs_per_node is not None and num_procs != num_nodes * procs_per_node: + raise ExecutorException("num_procs must equal num_nodes * procs_per_node for FluxExecutor") + + command = shlex.split(task.app.app_cmd) + if task.app_args: + command.extend(shlex.split(task.app_args)) + + command = self._set_sim_dir_env(task, command) + task.runline = " ".join(command) + + if dry_run: + logger.info(f"Test (No submit) Command: {task.runline}") + logger.info(f" num_procs={num_procs}, num_nodes={num_nodes}, procs_per_node={procs_per_node}") + task._set_complete() + else: + # Create Flux jobspec + try: + gpus_per_task = None + if num_gpus is not None: + if num_gpus < 0: + raise ExecutorException("num_gpus must be non-negative") + if num_gpus and num_gpus % num_procs != 0: + raise ExecutorException("num_gpus must be divisible by num_procs for FluxExecutor") + gpus_per_task = num_gpus // num_procs if num_gpus else 0 + + jobspec = JobspecV1.from_command( + command, + num_tasks=num_procs, + num_nodes=num_nodes, + cores_per_task=1, + gpus_per_task=gpus_per_task, + cwd=task.workdir, + environment=dict(os.environ), + ) + + if stdout: + jobspec.stdout = os.path.join(task.workdir, stdout) + if stderr: + jobspec.stderr = os.path.join(task.workdir, stderr) + if gpus_per_task: + jobspec.setattr_shell_option("gpu-affinity", "per-task") + + logger.info(f"Submitting Flux job for task {task.name}: {task.runline}") + task.flux_jobid = flux.job.submit(self.flux_handle, jobspec) + logger.info(f"Task {task.name} submitted with Flux job ID {task.flux_jobid}") + + task.timer.start() + task.submit_time = task.timer.tstart + + if wait_on_start: + self._wait_on_start(task) + + except Exception as e: + logger.error(f"Failed to submit Flux job: {e}") + task.state = "FAILED_TO_START" + task.finished = True + raise ExecutorException(f"Failed to submit Flux job: {e}") + + self.list_of_tasks.append(task) + return task + + def _wait_on_start(self, task: FluxTask, timeout: float = 60.0) -> None: + """Wait for a task to start running.""" + start = time.time() + task.timer.start() + task.submit_time = task.timer.tstart + + while task.state in ("CREATED", "WAITING"): + time.sleep(0.1) + task.poll() + if time.time() - start > timeout: + logger.warning(f"Timeout waiting for task {task.name} to start") + break + + if not task.finished: + task.timer.start() + task.submit_time = task.timer.tstart + + logger.debug(f"Task {task.name} polled as {task.state} after {time.time() - start:.2f} seconds") diff --git a/libensemble/tests/unit_tests/test_flux.py b/libensemble/tests/unit_tests/test_flux.py index b5f7c0e01..cc8b2a2ec 100644 --- a/libensemble/tests/unit_tests/test_flux.py +++ b/libensemble/tests/unit_tests/test_flux.py @@ -6,6 +6,7 @@ - Flux nodelist parsing (via slurm-style bracket notation) - Flux MPI variant detection - FluxAllocation platform configuration +- FluxExecutor (when flux bindings available) """ import os @@ -15,6 +16,7 @@ import pytest +from libensemble.executors import flux_executor from libensemble.executors.mpi_runner import FLUX_MPIRunner, MPIRunner from libensemble.resources.env_resources import EnvResources from libensemble.resources.platforms import FluxAllocation, Known_platforms @@ -317,6 +319,136 @@ class MockCls: check_mpi_runner_type(MockCls, "invalid_runner") +# ======================================================================================== +# Tests for FluxExecutor (conditional on flux availability) +# ======================================================================================== + + +def test_flux_executor_import_without_flux(): + """Test FluxExecutor handles missing flux gracefully""" + # This test just verifies the module can be imported + # even when flux is not available + try: + from libensemble.executors import flux_executor + + # FLUX_AVAILABLE should be False if flux not installed + # This is fine - we just want to ensure import doesn't crash + assert hasattr(flux_executor, "FLUX_AVAILABLE") + except ImportError: + pytest.skip("flux_executor module not available") + + +def test_flux_executor_requires_flux_uri(): + """Test FluxExecutor raises error when FLUX_URI not set""" + try: + from libensemble.executors.flux_executor import FLUX_AVAILABLE, FluxExecutor + + if not FLUX_AVAILABLE: + pytest.skip("Flux Python bindings not available") + + # Save and clear FLUX_URI + old_uri = os.environ.get("FLUX_URI") + if "FLUX_URI" in os.environ: + del os.environ["FLUX_URI"] + + try: + from libensemble.executors.executor import ExecutorException + + with pytest.raises(ExecutorException, match="FLUX_URI"): + FluxExecutor() + finally: + if old_uri: + os.environ["FLUX_URI"] = old_uri + + except ImportError: + pytest.skip("flux_executor module not available") + + +def test_flux_task_poll_uses_get_job(): + """Test FluxTask polls using Flux's get_job helper""" + if not flux_executor.FLUX_AVAILABLE: + pytest.skip("Flux Python bindings not available") + + task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=False, + ) + task.flux_handle = object() + task.flux_jobid = 123 + task.timer.start() + task.submit_time = task.timer.tstart + + with mock.patch.object(flux_executor.flux.job, "get_job", return_value={"state": "RUN"}) as mock_get_job: + task.poll() + + mock_get_job.assert_called_once_with(task.flux_handle, task.flux_jobid) + assert task.state == "RUNNING" + + +def test_flux_executor_submit_builds_jobspec_with_environment_and_gpus(): + """Test FluxExecutor submit passes environment and GPU resources via jobspec""" + if not flux_executor.FLUX_AVAILABLE: + pytest.skip("Flux Python bindings not available") + + executor = object.__new__(flux_executor.FluxExecutor) + executor.flux_handle = object() + executor.resources = None + executor.platform_info = {} + executor.workerID = 7 + executor.list_of_tasks = [] + executor.apps = {} + executor.default_apps = {"sim": None, "gen": None} + executor.base_dir = os.getcwd() + + app = SimpleNamespace( + name="sim", full_path="/path/to/sim.x", app_cmd="fluxwrap /path/to/sim.x", precedent="fluxwrap" + ) + executor.get_app = lambda app_name: app + executor.default_app = lambda calc_type: app + executor._check_app_exists = lambda app_obj: None + + old_env = os.environ.get("TEST_FLUX_ENV") + os.environ["TEST_FLUX_ENV"] = "present" + + jobspec = SimpleNamespace(stdout=None, stderr=None) + submit_calls = [] + + def fake_from_command(command, **kwargs): + submit_calls.append((command, kwargs)) + jobspec.cwd = kwargs.get("cwd") + jobspec.environment = kwargs.get("environment") + jobspec.setattr_shell_option = mock.Mock() + return jobspec + + try: + with ( + mock.patch.object(flux_executor.JobspecV1, "from_command", side_effect=fake_from_command), + mock.patch.object(flux_executor.flux.job, "submit", return_value=42), + ): + task = executor.submit(app_name="sim", num_procs=4, num_nodes=2, num_gpus=4, app_args="--flag value") + finally: + if old_env is None: + del os.environ["TEST_FLUX_ENV"] + else: + os.environ["TEST_FLUX_ENV"] = old_env + + command, kwargs = submit_calls[0] + assert command[:2] == ["fluxwrap", "/path/to/sim.x"] + assert command[-2:] == ["--flag", "value"] + assert kwargs["num_tasks"] == 4 + assert kwargs["num_nodes"] == 2 + assert kwargs["gpus_per_task"] == 1 + assert kwargs["environment"]["TEST_FLUX_ENV"] == "present" + assert kwargs["environment"]["LIBENSEMBLE_SIM_DIR"] == "." + jobspec.setattr_shell_option.assert_called_once_with("gpu-affinity", "per-task") + assert task.flux_jobid == 42 + + # ======================================================================================== # Test runner standalone execution # ======================================================================================== From d965e6cf5e39f671d810de36373be9654da7b834 Mon Sep 17 00:00:00 2001 From: jlnav Date: Wed, 8 Jul 2026 10:19:38 -0500 Subject: [PATCH 10/18] remove ax gen_f . ensure ax is up-to-date in all extra jobs for the optimas/ax tests --- .github/workflows/extra.yml | 1 - docs/examples/ax_multitask.rst | 18 --- .../test_persistent_gp_multitask_ax.py | 115 ------------------ .../persistent_gp/run_example.py | 95 --------------- pixi.lock | 4 +- pyproject.toml | 12 +- 6 files changed, 4 insertions(+), 241 deletions(-) delete mode 100644 docs/examples/ax_multitask.rst delete mode 100644 libensemble/tests/regression_tests/test_persistent_gp_multitask_ax.py delete mode 100644 libensemble/tests/scaling_tests/persistent_gp/run_example.py diff --git a/.github/workflows/extra.yml b/.github/workflows/extra.yml index 1111d2852..10b320726 100644 --- a/.github/workflows/extra.yml +++ b/.github/workflows/extra.yml @@ -76,7 +76,6 @@ jobs: rm ./libensemble/tests/unit_tests/test_ufunc_runners.py # needs globus-compute rm ./libensemble/tests/regression_tests/test_gpCAM.py # needs gpcam, which doesn't build on 3.13 rm ./libensemble/tests/regression_tests/test_asktell_gpCAM.py # needs gpcam, which doesn't build on 3.13 - rm ./libensemble/tests/regression_tests/test_persistent_gp_multitask_ax.py # needs ax-platform, which doesn't yet support 3.14 rm ./libensemble/tests/regression_tests/test_optimas_ax_mf.py # needs ax-platform, which doesn't yet support 3.14 rm ./libensemble/tests/regression_tests/test_optimas_ax_sf.py # needs ax-platform, which doesn't yet support 3.14 diff --git a/docs/examples/ax_multitask.rst b/docs/examples/ax_multitask.rst deleted file mode 100644 index e3984f39a..000000000 --- a/docs/examples/ax_multitask.rst +++ /dev/null @@ -1,18 +0,0 @@ -persistent_ax_multitask ------------------------ - -Required: `ax-platform`_>=0.5.0 - -Example usage: gp_multitask_ax_ - -To install:: - - pip install ax-platform - -An example of the Ax multitask GP. - -.. automodule:: persistent_ax_multitask - :members: - -.. _`ax-platform`: https://github.com/facebook/Ax -.. _gp_multitask_ax: https://github.com/Libensemble/libensemble/blob/main/libensemble/tests/regression_tests/test_persistent_gp_multitask_ax.py diff --git a/libensemble/tests/regression_tests/test_persistent_gp_multitask_ax.py b/libensemble/tests/regression_tests/test_persistent_gp_multitask_ax.py deleted file mode 100644 index 79236b2db..000000000 --- a/libensemble/tests/regression_tests/test_persistent_gp_multitask_ax.py +++ /dev/null @@ -1,115 +0,0 @@ -""" -Example of multi-fidelity optimization using a persistent GP gen_func (calling -Ax). - - -Execute via one of the following commands: - mpiexec -np 4 python test_persistent_gp_multitask_ax.py - python test_persistent_gp_multitask_ax.py --nworkers 3 - python test_persistent_gp_multitask_ax.py --nworkers 3 --comms tcp - -When running with the above commands, the number of concurrent evaluations of -the objective function will be 3. - -""" - -# Do not change these lines - they are parsed by run-tests.sh -# TESTSUITE_COMMS: local mpi -# TESTSUITE_NPROCS: 4 -# TESTSUITE_EXTRA: true -# TESTSUITE_OS_SKIP: OSX - -import warnings - -import numpy as np - -from libensemble import logger -from libensemble.libE import libE -from libensemble.message_numbers import WORKER_DONE -from libensemble.tools import parse_args, save_libE_output - -# Ax uses a deprecated warn command. -warnings.filterwarnings("ignore", category=UserWarning) -warnings.filterwarnings("ignore", category=DeprecationWarning) -warnings.filterwarnings("ignore", category=FutureWarning) - -from libensemble.gen_funcs.persistent_ax_multitask import persistent_gp_mt_ax_gen_f - - -def run_simulation(H, persis_info, sim_specs, libE_info): - # Extract input parameters - values = list(H["x"][0]) - x0 = values[0] - x1 = values[1] - # Extract fidelity parameter - task = H["task"][0] - if task == "expensive_model": - z = 8 - elif task == "cheap_model": - z = 1 - print("in sim", task) - - libE_output = np.zeros(1, dtype=sim_specs["out"]) - calc_status = WORKER_DONE - - # Function that depends on the resolution parameter - libE_output["f"] = -(x0 + 10 * np.cos(x0 + 0.1 * z)) * (x1 + 5 * np.cos(x1 - 0.2 * z)) - - return libE_output, persis_info, calc_status - - -# Main block is necessary only when using local comms with spawn start method (default on macOS and Windows). -if __name__ == "__main__": - nworkers, is_manager, libE_specs, _ = parse_args() - - mt_params = { - "name_hifi": "expensive_model", - "name_lofi": "cheap_model", - "n_init_hifi": 4, - "n_init_lofi": 4, - "n_opt_hifi": 2, - "n_opt_lofi": 4, - } - - sim_specs = { - "sim_f": run_simulation, - "in": ["x", "task"], - "out": [("f", float)], - } - - gen_specs = { - # Generator function. Will randomly generate new sim inputs 'x'. - "gen_f": persistent_gp_mt_ax_gen_f, - # Generator input. This is a RNG, no need for inputs. - "in": ["sim_id", "x", "f", "task"], - "persis_in": ["sim_id", "x", "f", "task"], - "out": [ - # parameters to input into the simulation. - ("x", float, (2,)), - ("task", str, max(len(str(mt_params["name_hifi"])), len(str(mt_params["name_lofi"])))), - ("resource_sets", int), - ], - "async_return": False, - "batch_size": nworkers - 1, - "user": { - "range": [1, 8], - # Lower bound for the n parameters. - "lb": np.array([0, 0]), - # Upper bound for the n parameters. - "ub": np.array([15, 15]), - }, - } - gen_specs["user"] = {**gen_specs["user"], **mt_params} - - # libE logger - logger.set_level("INFO") - - # Exit criteria - exit_criteria = {"sim_max": 20} # Exit after running sim_max simulations - - # Run LibEnsemble, and store results in history array H - H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria, libE_specs=libE_specs) - - # Save results to numpy file - if is_manager: - save_libE_output(H, persis_info, __file__, nworkers) diff --git a/libensemble/tests/scaling_tests/persistent_gp/run_example.py b/libensemble/tests/scaling_tests/persistent_gp/run_example.py deleted file mode 100644 index b8a6f8150..000000000 --- a/libensemble/tests/scaling_tests/persistent_gp/run_example.py +++ /dev/null @@ -1,95 +0,0 @@ -""" -Example of optimization using a persistent GP gen_func, with multi-fidelity - -Usage: ------- -python run_example.py --nworkers 4 -""" - -import numpy as np - -from libensemble import logger -from libensemble.gen_funcs.persistent_ax_multitask import persistent_gp_mt_ax_gen_f -from libensemble.libE import libE -from libensemble.message_numbers import WORKER_DONE -from libensemble.tools import parse_args, save_libE_output - -nworkers, is_manager, libE_specs, _ = parse_args() - -mt_params = { - "name_hifi": "expensive_model", - "name_lofi": "cheap_model", - "n_init_hifi": 4, - "n_init_lofi": 4, - "n_opt_hifi": 2, - "n_opt_lofi": 4, -} - - -def run_simulation(H, persis_info, sim_specs, libE_info): - # Extract input parameters - values = list(H["x"][0]) - x0 = values[0] - x1 = values[1] - # Extract fidelity parameter - task = H["task"][0] - if task == "expensive_model": - z = 8 - elif task == "cheap_model": - z = 1 - - libE_output = np.zeros(1, dtype=sim_specs["out"]) - calc_status = WORKER_DONE - - # Function that depends on the resolution parameter - libE_output["f"] = -(x0 + 10 * np.cos(x0 + 0.1 * z)) * (x1 + 5 * np.cos(x1 - 0.2 * z)) - - return libE_output, persis_info, calc_status - - -sim_specs = { - "sim_f": run_simulation, - "in": ["x", "task"], - "out": [("f", float)], -} - -gen_specs = { - # Generator function. Will randomly generate new sim inputs 'x'. - "gen_f": persistent_gp_mt_ax_gen_f, - # Generator input. This is a RNG, no need for inputs. - "in": ["sim_id", "x", "f", "task"], - "persis_in": ["sim_id", "x", "f", "task"], - "out": [ - # parameters to input into the simulation. - ("x", float, (2,)), - ("task", str, max(len(str(mt_params["name_hifi"])), len(str(mt_params["name_lofi"])))), - ("resource_sets", int), - ], - "async_return": False, - "batch_size": nworkers - 1, - "user": { - "range": [1, 8], - # Lower bound for the n parameters. - "lb": np.array([0, 0]), - # Upper bound for the n parameters. - "ub": np.array([15, 15]), - }, -} -gen_specs["user"] = {**gen_specs["user"], **mt_params} - - -# libE logger -logger.set_level("INFO") - -# Exit criteria -exit_criteria = {"sim_max": 20} # Exit after running sim_max simulations - -# Create a different random number stream for each worker and the manager -persis_info = {} - -# Run LibEnsemble, and store results in history array H -H, persis_info, flag = libE(sim_specs, gen_specs, exit_criteria, persis_info, libE_specs=libE_specs) - -# Save results to numpy file -if is_manager: - save_libE_output(H, persis_info, __file__, nworkers) diff --git a/pixi.lock b/pixi.lock index c4714fc82..c91e89538 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd8596dc0210788ddfda1f23f01aa4a83aaf85d1242d5b68a530e350e14c174b -size 1084218 +oid sha256:08d5a4373069dc12cfc292c49bee812d7d138c226d005e04bdbb32f78b0513a5 +size 1234374 diff --git a/pyproject.toml b/pyproject.toml index 5302b3039..7e3d8eabe 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -121,6 +121,7 @@ petsc4py = "==3.24.2" pandas = "<3" numpy = "<2.4" proxystore = ">=0.7.1,<0.9" +ax-platform = ">=1.2.4,<2" [tool.pixi.feature.docs.dependencies] sphinx = ">=8.2.3,<9" @@ -166,22 +167,13 @@ python = "3.13.*" [tool.pixi.feature.py314.dependencies] python = "3.14.*" -# ax-platform only works up to 3.13 on Linux - -[tool.pixi.feature.py311e.target.linux-64.dependencies] -ax-platform = "==0.5.0" - [tool.pixi.feature.py311e.dependencies] globus-compute-sdk = ">=4.10.2,<5" -[tool.pixi.feature.py312e.target.linux-64.dependencies] -ax-platform = "==0.5.0" - [tool.pixi.feature.py312e.dependencies] globus-compute-sdk = ">=4.10.2,<5" -[tool.pixi.feature.py313e.target.linux-64.dependencies] -ax-platform = "==0.5.0" +[tool.pixi.feature.py313e] [tool.pixi.feature.py314e] From 42eaff67ee12ba2ae3c3eb821dd330a5cc552d25 Mon Sep 17 00:00:00 2001 From: jlnav Date: Thu, 9 Jul 2026 14:55:50 -0500 Subject: [PATCH 11/18] slim down some of the NPROCS test run-cases --- libensemble/tests/functionality_tests/test_1d_super_simple.py | 2 +- .../test_1d_uniform_sampling_with_comm_dup.py | 2 +- .../test_active_persistent_worker_abort.py | 2 +- .../functionality_tests/test_asktell_sampling_external_gen.py | 2 +- libensemble/tests/functionality_tests/test_calc_exception.py | 2 +- libensemble/tests/functionality_tests/test_comms.py | 2 +- .../tests/functionality_tests/test_elapsed_time_abort.py | 2 +- .../functionality_tests/test_evaluate_existing_plus_gen.py | 2 +- .../functionality_tests/test_executor_hworld_pass_fail.py | 2 +- .../tests/functionality_tests/test_executor_hworld_timeout.py | 2 +- libensemble/tests/functionality_tests/test_mpi_comms.py | 2 +- libensemble/tests/functionality_tests/test_mpi_runners.py | 2 +- .../functionality_tests/test_mpi_runners_supernode_uneven.py | 2 +- .../test_persistent_uniform_sampling_cancel.py | 2 +- .../test_persistent_uniform_sampling_nonblocking.py | 2 +- .../test_persistent_uniform_sampling_running_mean.py | 2 +- .../tests/functionality_tests/test_sim_dirs_per_calc.py | 2 +- .../tests/functionality_tests/test_sim_dirs_per_worker.py | 2 +- .../tests/functionality_tests/test_sim_dirs_with_exception.py | 2 +- .../tests/functionality_tests/test_sim_dirs_with_gen_dirs.py | 2 +- .../tests/functionality_tests/test_sim_input_dir_option.py | 2 +- .../tests/functionality_tests/test_uniform_sampling.py | 2 +- .../test_uniform_sampling_one_residual_at_a_time.py | 2 +- .../test_uniform_sampling_then_persistent_localopt_runs.py | 2 +- .../tests/functionality_tests/test_worker_exceptions.py | 2 +- libensemble/tests/functionality_tests/test_workflow_dir.py | 2 +- libensemble/tests/regression_tests/test_1d_sampling.py | 2 +- libensemble/tests/regression_tests/test_2d_sampling.py | 2 +- libensemble/tests/regression_tests/test_2d_sampling_vocs.py | 4 ++-- .../tests/regression_tests/test_evaluate_existing_sample.py | 2 +- .../tests/regression_tests/test_evaluate_mixed_sample.py | 2 +- .../tests/regression_tests/test_inverse_bayes_example.py | 2 +- .../regression_tests/test_persistent_surmise_killsims.py | 2 +- 33 files changed, 34 insertions(+), 34 deletions(-) diff --git a/libensemble/tests/functionality_tests/test_1d_super_simple.py b/libensemble/tests/functionality_tests/test_1d_super_simple.py index 1a178c2cf..f0b6d3f0b 100644 --- a/libensemble/tests/functionality_tests/test_1d_super_simple.py +++ b/libensemble/tests/functionality_tests/test_1d_super_simple.py @@ -11,7 +11,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import numpy as np diff --git a/libensemble/tests/functionality_tests/test_1d_uniform_sampling_with_comm_dup.py b/libensemble/tests/functionality_tests/test_1d_uniform_sampling_with_comm_dup.py index 4bdb60e0c..b1ff3d5df 100644 --- a/libensemble/tests/functionality_tests/test_1d_uniform_sampling_with_comm_dup.py +++ b/libensemble/tests/functionality_tests/test_1d_uniform_sampling_with_comm_dup.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 # TESTSUITE_OS_SKIP: WIN import sys diff --git a/libensemble/tests/functionality_tests/test_active_persistent_worker_abort.py b/libensemble/tests/functionality_tests/test_active_persistent_worker_abort.py index 4a7b47c0a..60d3401fe 100644 --- a/libensemble/tests/functionality_tests/test_active_persistent_worker_abort.py +++ b/libensemble/tests/functionality_tests/test_active_persistent_worker_abort.py @@ -13,7 +13,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 3 4 +# TESTSUITE_NPROCS: 4 # TESTSUITE_EXTRA: true import sys diff --git a/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py index 463fbbb0e..f6b703b58 100644 --- a/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py +++ b/libensemble/tests/functionality_tests/test_asktell_sampling_external_gen.py @@ -13,7 +13,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import numpy as np from gest_api.vocs import VOCS diff --git a/libensemble/tests/functionality_tests/test_calc_exception.py b/libensemble/tests/functionality_tests/test_calc_exception.py index 55d99ca63..2bae8eede 100644 --- a/libensemble/tests/functionality_tests/test_calc_exception.py +++ b/libensemble/tests/functionality_tests/test_calc_exception.py @@ -9,7 +9,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import numpy as np diff --git a/libensemble/tests/functionality_tests/test_comms.py b/libensemble/tests/functionality_tests/test_comms.py index daa9e564c..b9ee03f24 100644 --- a/libensemble/tests/functionality_tests/test_comms.py +++ b/libensemble/tests/functionality_tests/test_comms.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import numpy as np diff --git a/libensemble/tests/functionality_tests/test_elapsed_time_abort.py b/libensemble/tests/functionality_tests/test_elapsed_time_abort.py index 1396da50f..5569faa76 100644 --- a/libensemble/tests/functionality_tests/test_elapsed_time_abort.py +++ b/libensemble/tests/functionality_tests/test_elapsed_time_abort.py @@ -11,7 +11,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import numpy as np diff --git a/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py b/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py index 3e37bc86d..a5385e225 100644 --- a/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py +++ b/libensemble/tests/functionality_tests/test_evaluate_existing_plus_gen.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import numpy as np diff --git a/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py b/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py index a65462e0d..6e664bec2 100644 --- a/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py +++ b/libensemble/tests/functionality_tests/test_executor_hworld_pass_fail.py @@ -29,7 +29,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp # TESTSUITE_OS_SKIP: OSX WIN -# TESTSUITE_NPROCS: 2 3 4 +# TESTSUITE_NPROCS: 3 4 # TESTSUITE_OMPI_SKIP: true # TESTSUITE_EXTRA: true diff --git a/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py b/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py index 2a604007e..7727fc754 100644 --- a/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py +++ b/libensemble/tests/functionality_tests/test_executor_hworld_timeout.py @@ -27,7 +27,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 3 4 +# TESTSUITE_NPROCS: 3 4 # TESTSUITE_OMPI_SKIP: true # TESTSUITE_OS_SKIP: OSX WIN # TESTSUITE_EXTRA: true diff --git a/libensemble/tests/functionality_tests/test_mpi_comms.py b/libensemble/tests/functionality_tests/test_mpi_comms.py index 73ca83152..6ccd68305 100644 --- a/libensemble/tests/functionality_tests/test_mpi_comms.py +++ b/libensemble/tests/functionality_tests/test_mpi_comms.py @@ -14,7 +14,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 # Main block is necessary only when using local comms with spawn start method (default on macOS and Windows). if __name__ == "__main__": diff --git a/libensemble/tests/functionality_tests/test_mpi_runners.py b/libensemble/tests/functionality_tests/test_mpi_runners.py index 346c22485..da141f568 100644 --- a/libensemble/tests/functionality_tests/test_mpi_runners.py +++ b/libensemble/tests/functionality_tests/test_mpi_runners.py @@ -24,7 +24,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 # Main block is necessary only when using local comms with spawn start method (default on macOS and Windows). if __name__ == "__main__": diff --git a/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py b/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py index 428297d5d..779969d1d 100644 --- a/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py +++ b/libensemble/tests/functionality_tests/test_mpi_runners_supernode_uneven.py @@ -24,7 +24,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local -# TESTSUITE_NPROCS: 3 4 5 +# TESTSUITE_NPROCS: 4 5 # Main block is necessary only when using local comms with spawn start method (default on macOS and Windows). if __name__ == "__main__": diff --git a/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_cancel.py b/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_cancel.py index 9068b8313..c8e009012 100644 --- a/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_cancel.py +++ b/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_cancel.py @@ -14,7 +14,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local -# TESTSUITE_NPROCS: 3 4 +# TESTSUITE_NPROCS: 4 import sys diff --git a/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_nonblocking.py b/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_nonblocking.py index 2a4877cf0..989222594 100644 --- a/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_nonblocking.py +++ b/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_nonblocking.py @@ -14,7 +14,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 3 4 +# TESTSUITE_NPROCS: 4 import sys diff --git a/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_running_mean.py b/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_running_mean.py index acd15d370..c6be008da 100644 --- a/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_running_mean.py +++ b/libensemble/tests/functionality_tests/test_persistent_uniform_sampling_running_mean.py @@ -14,7 +14,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local -# TESTSUITE_NPROCS: 3 4 +# TESTSUITE_NPROCS: 4 import sys diff --git a/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py b/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py index baa34b838..c5df46265 100644 --- a/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py +++ b/libensemble/tests/functionality_tests/test_sim_dirs_per_calc.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import os diff --git a/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py b/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py index 74c77252c..0b8dcc7b6 100644 --- a/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py +++ b/libensemble/tests/functionality_tests/test_sim_dirs_per_worker.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import os diff --git a/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py b/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py index b3189dc1a..5dec9013e 100644 --- a/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py +++ b/libensemble/tests/functionality_tests/test_sim_dirs_with_exception.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import os diff --git a/libensemble/tests/functionality_tests/test_sim_dirs_with_gen_dirs.py b/libensemble/tests/functionality_tests/test_sim_dirs_with_gen_dirs.py index a636cc2f0..c99a07899 100644 --- a/libensemble/tests/functionality_tests/test_sim_dirs_with_gen_dirs.py +++ b/libensemble/tests/functionality_tests/test_sim_dirs_with_gen_dirs.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import os diff --git a/libensemble/tests/functionality_tests/test_sim_input_dir_option.py b/libensemble/tests/functionality_tests/test_sim_input_dir_option.py index 4e58d27d6..5a614b601 100644 --- a/libensemble/tests/functionality_tests/test_sim_input_dir_option.py +++ b/libensemble/tests/functionality_tests/test_sim_input_dir_option.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import os diff --git a/libensemble/tests/functionality_tests/test_uniform_sampling.py b/libensemble/tests/functionality_tests/test_uniform_sampling.py index 4d8a6baa7..3e1b97123 100644 --- a/libensemble/tests/functionality_tests/test_uniform_sampling.py +++ b/libensemble/tests/functionality_tests/test_uniform_sampling.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import datetime import os diff --git a/libensemble/tests/functionality_tests/test_uniform_sampling_one_residual_at_a_time.py b/libensemble/tests/functionality_tests/test_uniform_sampling_one_residual_at_a_time.py index f44b117c2..68364a3ea 100644 --- a/libensemble/tests/functionality_tests/test_uniform_sampling_one_residual_at_a_time.py +++ b/libensemble/tests/functionality_tests/test_uniform_sampling_one_residual_at_a_time.py @@ -15,7 +15,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import sys from copy import deepcopy diff --git a/libensemble/tests/functionality_tests/test_uniform_sampling_then_persistent_localopt_runs.py b/libensemble/tests/functionality_tests/test_uniform_sampling_then_persistent_localopt_runs.py index 7367d13b2..ae8b8b4f8 100644 --- a/libensemble/tests/functionality_tests/test_uniform_sampling_then_persistent_localopt_runs.py +++ b/libensemble/tests/functionality_tests/test_uniform_sampling_then_persistent_localopt_runs.py @@ -14,7 +14,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 3 4 +# TESTSUITE_NPROCS: 4 # TESTSUITE_EXTRA: true import sys diff --git a/libensemble/tests/functionality_tests/test_worker_exceptions.py b/libensemble/tests/functionality_tests/test_worker_exceptions.py index efdba0ec4..0833521d6 100644 --- a/libensemble/tests/functionality_tests/test_worker_exceptions.py +++ b/libensemble/tests/functionality_tests/test_worker_exceptions.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import numpy as np diff --git a/libensemble/tests/functionality_tests/test_workflow_dir.py b/libensemble/tests/functionality_tests/test_workflow_dir.py index 6502b78ed..f1e9c4385 100644 --- a/libensemble/tests/functionality_tests/test_workflow_dir.py +++ b/libensemble/tests/functionality_tests/test_workflow_dir.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import os diff --git a/libensemble/tests/regression_tests/test_1d_sampling.py b/libensemble/tests/regression_tests/test_1d_sampling.py index 456b3ca60..18acc2097 100644 --- a/libensemble/tests/regression_tests/test_1d_sampling.py +++ b/libensemble/tests/regression_tests/test_1d_sampling.py @@ -11,7 +11,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local threads tcp -# TESTSUITE_NPROCS: 3 4 +# TESTSUITE_NPROCS: 4 import numpy as np diff --git a/libensemble/tests/regression_tests/test_2d_sampling.py b/libensemble/tests/regression_tests/test_2d_sampling.py index c26a66201..e484e3222 100644 --- a/libensemble/tests/regression_tests/test_2d_sampling.py +++ b/libensemble/tests/regression_tests/test_2d_sampling.py @@ -11,7 +11,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local threads tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import numpy as np diff --git a/libensemble/tests/regression_tests/test_2d_sampling_vocs.py b/libensemble/tests/regression_tests/test_2d_sampling_vocs.py index f535e1709..594e1a5c5 100644 --- a/libensemble/tests/regression_tests/test_2d_sampling_vocs.py +++ b/libensemble/tests/regression_tests/test_2d_sampling_vocs.py @@ -10,7 +10,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local threads tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import numpy as np from gest_api.vocs import VOCS @@ -53,6 +53,6 @@ def sim_f(In, persis_info, sim_specs, _): x0 = sampling.H["x0"] x1 = sampling.H["x1"] f = sampling.H["f"] - assert np.all(np.isclose(f, np.sqrt(x0 ** 2 + x1 ** 2))) + assert np.all(np.isclose(f, np.sqrt(x0**2 + x1**2))) print("\nlibEnsemble has calculated the 2D vector norm of all points") sampling.save_output(__file__) diff --git a/libensemble/tests/regression_tests/test_evaluate_existing_sample.py b/libensemble/tests/regression_tests/test_evaluate_existing_sample.py index 8f1ee674c..3aac366e7 100644 --- a/libensemble/tests/regression_tests/test_evaluate_existing_sample.py +++ b/libensemble/tests/regression_tests/test_evaluate_existing_sample.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import numpy as np diff --git a/libensemble/tests/regression_tests/test_evaluate_mixed_sample.py b/libensemble/tests/regression_tests/test_evaluate_mixed_sample.py index 60e43fa57..38f9566fe 100644 --- a/libensemble/tests/regression_tests/test_evaluate_mixed_sample.py +++ b/libensemble/tests/regression_tests/test_evaluate_mixed_sample.py @@ -12,7 +12,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 2 4 +# TESTSUITE_NPROCS: 4 import warnings diff --git a/libensemble/tests/regression_tests/test_inverse_bayes_example.py b/libensemble/tests/regression_tests/test_inverse_bayes_example.py index 72fa6eed0..7676dd807 100644 --- a/libensemble/tests/regression_tests/test_inverse_bayes_example.py +++ b/libensemble/tests/regression_tests/test_inverse_bayes_example.py @@ -16,7 +16,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 3 4 +# TESTSUITE_NPROCS: 4 import numpy as np diff --git a/libensemble/tests/regression_tests/test_persistent_surmise_killsims.py b/libensemble/tests/regression_tests/test_persistent_surmise_killsims.py index aeb10f4f5..d3f79de1c 100644 --- a/libensemble/tests/regression_tests/test_persistent_surmise_killsims.py +++ b/libensemble/tests/regression_tests/test_persistent_surmise_killsims.py @@ -23,7 +23,7 @@ # Do not change these lines - they are parsed by run-tests.sh # TESTSUITE_COMMS: mpi local tcp -# TESTSUITE_NPROCS: 3 4 +# TESTSUITE_NPROCS: 4 # TESTSUITE_EXTRA: true # TESTSUITE_OS_SKIP: OSX From ed5999acb748f8978c014f5ed1e77cd9e5d88d20 Mon Sep 17 00:00:00 2001 From: jlnav Date: Fri, 10 Jul 2026 11:57:54 -0500 Subject: [PATCH 12/18] add flux-core / flux-python to py312e and py313e environments --- pixi.lock | 4 ++-- pyproject.toml | 8 ++++++++ 2 files changed, 10 insertions(+), 2 deletions(-) diff --git a/pixi.lock b/pixi.lock index c4714fc82..735d8edd6 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:dd8596dc0210788ddfda1f23f01aa4a83aaf85d1242d5b68a530e350e14c174b -size 1084218 +oid sha256:946c935276cd71d36d0e2ef1cf79ef5c2959b43a3a0f734982728e06e3d4e069 +size 1098570 diff --git a/pyproject.toml b/pyproject.toml index db66bf8fd..374e9065d 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -176,12 +176,20 @@ globus-compute-sdk = ">=4.10.2,<5" [tool.pixi.feature.py312e.target.linux-64.dependencies] ax-platform = "==0.5.0" +flux-core = ">=0.81.0,<0.82" + +[tool.pixi.feature.py312e.target.linux-64.pypi-dependencies] +flux-python = ">=0.81.0, <0.82" [tool.pixi.feature.py312e.dependencies] globus-compute-sdk = ">=4.10.2,<5" [tool.pixi.feature.py313e.target.linux-64.dependencies] ax-platform = "==0.5.0" +flux-core = ">=0.81.0,<0.82" + +[tool.pixi.feature.py313e.target.linux-64.pypi-dependencies] +flux-python = ">=0.81.0, <0.82" [tool.pixi.feature.py314e] From 8dbfe95928bd9309d33843cca730cc16747da3cd Mon Sep 17 00:00:00 2001 From: jlnav Date: Fri, 10 Jul 2026 13:25:10 -0500 Subject: [PATCH 13/18] additional tests/coverage attempts --- libensemble/tests/unit_tests/test_flux.py | 242 ++++++++++++++++++++++ 1 file changed, 242 insertions(+) diff --git a/libensemble/tests/unit_tests/test_flux.py b/libensemble/tests/unit_tests/test_flux.py index cc8b2a2ec..66150a504 100644 --- a/libensemble/tests/unit_tests/test_flux.py +++ b/libensemble/tests/unit_tests/test_flux.py @@ -17,6 +17,7 @@ import pytest from libensemble.executors import flux_executor +from libensemble.executors.executor import TimeoutExpired from libensemble.executors.mpi_runner import FLUX_MPIRunner, MPIRunner from libensemble.resources.env_resources import EnvResources from libensemble.resources.platforms import FluxAllocation, Known_platforms @@ -449,6 +450,247 @@ def fake_from_command(command, **kwargs): assert task.flux_jobid == 42 +def test_flux_executor_init_connects_with_flux_uri(): + """Test FluxExecutor initializes when Flux bindings and FLUX_URI are available.""" + fake_flux_module = SimpleNamespace(Flux=mock.Mock(return_value="flux-handle")) + + with ( + mock.patch.object(flux_executor, "FLUX_AVAILABLE", True), + mock.patch.object(flux_executor, "flux", fake_flux_module), + mock.patch.dict(os.environ, {"FLUX_URI": "local:///tmp/flux-test"}, clear=False), + ): + executor = flux_executor.FluxExecutor() + + fake_flux_module.Flux.assert_called_once_with() + assert executor.flux_handle == "flux-handle" + assert executor.resources is None + assert executor.platform_info == {} + + +def test_flux_executor_wait_on_start_polls_until_running(): + """Test FluxExecutor waits for a FluxTask to leave the startup states.""" + executor = object.__new__(flux_executor.FluxExecutor) + task = SimpleNamespace( + name="flux-task", + state="CREATED", + finished=False, + timer=SimpleNamespace(tstart=None, start=mock.Mock(side_effect=lambda: setattr(task.timer, "tstart", 1.23))), + submit_time=None, + ) + + def poll_side_effect(): + task.state = "RUNNING" + + task.poll = mock.Mock(side_effect=poll_side_effect) + + with mock.patch.object(flux_executor.time, "sleep"): + executor._wait_on_start(task, timeout=0.5) + + assert task.poll.call_count == 1 + assert task.state == "RUNNING" + assert task.timer.start.call_count == 2 + assert task.submit_time == 1.23 + + +def test_flux_task_poll_maps_completion_waiting_and_unknown_states(): + """Test FluxTask poll maps Flux job states to libEnsemble states.""" + task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=False, + ) + task.flux_handle = object() + task.flux_jobid = 123 + task.timer.start() + task.submit_time = task.timer.tstart + fake_flux = SimpleNamespace(job=SimpleNamespace(get_job=mock.Mock())) + + with mock.patch.object(flux_executor, "flux", fake_flux): + fake_flux.job.get_job.return_value = {"state": "SCHED"} + task.poll() + assert task.state == "WAITING" + assert not task.finished + + with mock.patch.object(task, "_handle_completion") as mock_handle_completion: + fake_flux.job.get_job.return_value = {"state": "INACTIVE"} + task.poll() + mock_handle_completion.assert_called_once_with({"state": "INACTIVE"}) + + fake_flux.job.get_job.return_value = {"state": "MYSTERY"} + task.finished = False + task.poll() + + assert task.state == "UNKNOWN" + + +def test_flux_task_handle_completion_success_and_failure(): + """Test FluxTask completion handling sets success, state, and errcode.""" + success_task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=False, + ) + success_task.timer.start() + success_task.submit_time = success_task.timer.tstart + success_task._handle_completion({"state": "INACTIVE", "result": "COMPLETED", "returncode": 0}) + assert success_task.finished is True + assert success_task.success is True + assert success_task.state == "FINISHED" + assert success_task.errcode == 0 + + failed_task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=False, + ) + failed_task.timer.start() + failed_task.submit_time = failed_task.timer.tstart + failed_task._handle_completion({"state": "INACTIVE", "result": "FAILED", "returncode": 7}) + assert failed_task.finished is True + assert failed_task.success is False + assert failed_task.state == "FAILED" + assert failed_task.errcode == 7 + + +def test_flux_task_set_complete_handles_dry_run_and_return_codes(): + """Test FluxTask _set_complete for dry-run and non-dry-run tasks.""" + dry_run_task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=True, + ) + dry_run_task._set_complete() + assert dry_run_task.finished is True + assert dry_run_task.success is True + assert dry_run_task.state == "FINISHED" + + finished_task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=False, + ) + finished_task.errcode = 3 + finished_task.timer.start() + finished_task.submit_time = finished_task.timer.tstart + finished_task._set_complete() + assert finished_task.finished is True + assert finished_task.success is False + assert finished_task.state == "FAILED" + + +def test_flux_task_wait_completes_and_times_out(): + """Test FluxTask wait completes after polling and raises on timeout.""" + task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=False, + ) + task.flux_handle = object() + task.flux_jobid = 123 + + def complete_on_second_poll(): + complete_on_second_poll.calls += 1 + if complete_on_second_poll.calls == 1: + task.state = "RUNNING" + else: + task.finished = True + task.state = "FINISHED" + + complete_on_second_poll.calls = 0 + task.poll = mock.Mock(side_effect=complete_on_second_poll) + + with mock.patch.object(flux_executor.time, "sleep"): + task.wait(timeout=1.0) + + assert task.finished is True + assert task.state == "FINISHED" + assert task.poll.call_count == 2 + + timeout_task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=False, + ) + timeout_task.flux_handle = object() + timeout_task.flux_jobid = 456 + timeout_task.poll = mock.Mock(side_effect=lambda: setattr(timeout_task, "state", "RUNNING")) + + with ( + mock.patch.object(flux_executor.time, "sleep"), + mock.patch.object(flux_executor.time, "time", side_effect=[0.0, 0.2]), + ): + with pytest.raises(TimeoutExpired): + timeout_task.wait(timeout=0.1) + + +def test_flux_task_kill_cancels_and_marks_user_killed(): + """Test FluxTask kill cancels the job and marks the task as user-killed.""" + task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=False, + ) + task.flux_handle = object() + task.flux_jobid = 789 + task.timer.start() + task.submit_time = task.timer.tstart + + def poll_side_effect(): + if poll_side_effect.calls == 0: + task.state = "RUNNING" + else: + task.finished = True + task.state = "FAILED" + poll_side_effect.calls += 1 + + poll_side_effect.calls = 0 + task.poll = mock.Mock(side_effect=poll_side_effect) + fake_flux = SimpleNamespace(job=SimpleNamespace(cancel=mock.Mock())) + + with ( + mock.patch.object(flux_executor, "flux", fake_flux), + mock.patch.object(flux_executor.time, "sleep"), + mock.patch.object(flux_executor.time, "time", side_effect=[0.0, 0.0, 0.2]), + ): + task.kill(wait_time=1) + + fake_flux.job.cancel.assert_called_once_with(task.flux_handle, task.flux_jobid) + assert task.state == "USER_KILLED" + assert task.finished is True + + # ======================================================================================== # Test runner standalone execution # ======================================================================================== From ee3720fc91232d9f8ea0e35026ca8a8fa9aa1be0 Mon Sep 17 00:00:00 2001 From: jlnav Date: Fri, 17 Jul 2026 13:13:50 -0500 Subject: [PATCH 14/18] remove upper-bound of gest-api dependency --- pyproject.toml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyproject.toml b/pyproject.toml index 7e3d8eabe..b2a45ae93 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -8,7 +8,7 @@ authors = [ { name = "John-Luke Navarro" }, ] -dependencies = ["numpy", "psutil", "pydantic", "gest-api>=0.1,<0.2"] +dependencies = ["numpy", "psutil", "pydantic", "gest-api"] description = "A Python toolkit for coordinating asynchronous and dynamic ensembles of calculations." name = "libensemble" From a5d67ad58de419d333097d2d0e2e7770d9828759 Mon Sep 17 00:00:00 2001 From: jlnav Date: Fri, 17 Jul 2026 13:18:58 -0500 Subject: [PATCH 15/18] update lockfile --- pixi.lock | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pixi.lock b/pixi.lock index c91e89538..bed387508 100644 --- a/pixi.lock +++ b/pixi.lock @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:08d5a4373069dc12cfc292c49bee812d7d138c226d005e04bdbb32f78b0513a5 -size 1234374 +oid sha256:f421728ebadb7a6e602e41cfbf8baf7e43db5762a0e788ee8267846923898035 +size 1234364 From b919f2d425728f5fe55c607cda9b108a5a2d354e Mon Sep 17 00:00:00 2001 From: jlnav Date: Tue, 21 Jul 2026 14:21:04 -0500 Subject: [PATCH 16/18] additional tests, plus coverage adjusts, plus remove an exception block that can't happen --- libensemble/executors/flux_executor.py | 46 +++++------------- libensemble/tests/unit_tests/test_flux.py | 59 ++++++++++++++++++++++- 2 files changed, 70 insertions(+), 35 deletions(-) diff --git a/libensemble/executors/flux_executor.py b/libensemble/executors/flux_executor.py index e2b1aa2e3..ea3163ae4 100644 --- a/libensemble/executors/flux_executor.py +++ b/libensemble/executors/flux_executor.py @@ -166,27 +166,19 @@ def wait(self, timeout: float | None = None) -> None: if not self._check_poll(): return - try: - # Wait for job to complete - start_time = time.time() - while True: - self.poll() - if self.finished: - break - - if timeout is not None: - elapsed = time.time() - start_time - if elapsed >= timeout: - raise TimeoutExpired(self.name, timeout) - - time.sleep(0.1) - - except TimeoutExpired: - raise - except Exception as e: - logger.warning(f"Error waiting for Flux job {self.flux_jobid}: {e}") - self.state = "FAILED" - self.finished = True + # Wait for job to complete + start_time = time.time() + while True: + self.poll() + if self.finished: + break + + if timeout is not None: + elapsed = time.time() - start_time + if elapsed >= timeout: + raise TimeoutExpired(self.name, timeout) + + time.sleep(0.1) def kill(self, wait_time: int | None = 60) -> None: """Kills/cancels the Flux job. @@ -205,10 +197,6 @@ def kill(self, wait_time: int | None = 60) -> None: logger.warning(f"Trying to kill task that is no longer running. Task {self.name}: Status is {self.state}") return - if self.flux_jobid is None: - logger.warning(f"Task {self.name} has no Flux job ID - cannot kill") - return - logger.info(f"Canceling Flux job {self.flux_jobid} for task {self.name}") try: @@ -287,14 +275,6 @@ def __init__(self) -> None: self.resources = None self.platform_info: dict = {} - def set_resources(self, resources) -> None: - """Set resources for the executor.""" - self.resources = resources - - def add_platform_info(self, platform_info: dict | None = None) -> None: - """Add platform info to the executor.""" - self.platform_info = platform_info or {} - def submit( self, calc_type: str | None = None, diff --git a/libensemble/tests/unit_tests/test_flux.py b/libensemble/tests/unit_tests/test_flux.py index 66150a504..eb0b03b0b 100644 --- a/libensemble/tests/unit_tests/test_flux.py +++ b/libensemble/tests/unit_tests/test_flux.py @@ -398,7 +398,6 @@ def test_flux_executor_submit_builds_jobspec_with_environment_and_gpus(): executor = object.__new__(flux_executor.FluxExecutor) executor.flux_handle = object() - executor.resources = None executor.platform_info = {} executor.workerID = 7 executor.list_of_tasks = [] @@ -463,7 +462,6 @@ def test_flux_executor_init_connects_with_flux_uri(): fake_flux_module.Flux.assert_called_once_with() assert executor.flux_handle == "flux-handle" - assert executor.resources is None assert executor.platform_info == {} @@ -597,6 +595,52 @@ def test_flux_task_set_complete_handles_dry_run_and_return_codes(): assert finished_task.success is False assert finished_task.state == "FAILED" + # cover waiting on a task that completes before timeout + task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=False, + ) + task._set_complete() + task.flux_jobid = 123 + task.wait(timeout=10) + task.kill() + + +def test_flux_task_dry_run_exception_and_kill(): + """Test FluxTask dry run exception attributes.""" + task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=True, + ) + task.wait() + assert task.finished is True + assert task.success is True + assert task.state == "FINISHED" + task.kill() + task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=True, + ) + task.poll() + assert task.finished is True + assert task.success is True + assert task.state == "FINISHED" + def test_flux_task_wait_completes_and_times_out(): """Test FluxTask wait completes after polling and raises on timeout.""" @@ -716,6 +760,7 @@ def poll_side_effect(): # Validator tests test_validator_accepts_flux() test_validator_accepts_all_runners() + test_validator_rejects_invalid() # Platform tests test_flux_allocation_platform() @@ -724,4 +769,14 @@ def poll_side_effect(): # EnvResources tests test_env_resources_flux_env_variable() + # Flux Executor tests + test_flux_executor_init_connects_with_flux_uri() + test_flux_executor_wait_on_start_polls_until_running() + test_flux_task_poll_maps_completion_waiting_and_unknown_states() + test_flux_task_handle_completion_success_and_failure() + test_flux_task_set_complete_handles_dry_run_and_return_codes() + test_flux_task_dry_run_exception_and_kill() + test_flux_task_wait_completes_and_times_out() + test_flux_task_kill_cancels_and_marks_user_killed() + print("All standalone tests passed!") From b60592a65eca64883b4db78ac9576c6aa6420383 Mon Sep 17 00:00:00 2001 From: jlnav Date: Wed, 22 Jul 2026 12:40:47 -0500 Subject: [PATCH 17/18] vibe-coded coverage for FluxExecutor.submit --- libensemble/tests/unit_tests/test_flux.py | 252 +++++++++++++++++++++- 1 file changed, 244 insertions(+), 8 deletions(-) diff --git a/libensemble/tests/unit_tests/test_flux.py b/libensemble/tests/unit_tests/test_flux.py index eb0b03b0b..cbfc67392 100644 --- a/libensemble/tests/unit_tests/test_flux.py +++ b/libensemble/tests/unit_tests/test_flux.py @@ -391,19 +391,22 @@ def test_flux_task_poll_uses_get_job(): assert task.state == "RUNNING" -def test_flux_executor_submit_builds_jobspec_with_environment_and_gpus(): - """Test FluxExecutor submit passes environment and GPU resources via jobspec""" - if not flux_executor.FLUX_AVAILABLE: - pytest.skip("Flux Python bindings not available") - +def _make_uninitialized_flux_executor(*, worker_id: int = 7): executor = object.__new__(flux_executor.FluxExecutor) executor.flux_handle = object() executor.platform_info = {} - executor.workerID = 7 + executor.workerID = worker_id executor.list_of_tasks = [] executor.apps = {} executor.default_apps = {"sim": None, "gen": None} executor.base_dir = os.getcwd() + return executor + + +def test_flux_executor_submit_builds_jobspec_with_environment_and_gpus(): + """Test FluxExecutor submit passes environment and GPU resources via jobspec""" + + executor = _make_uninitialized_flux_executor(worker_id=7) app = SimpleNamespace( name="sim", full_path="/path/to/sim.x", app_cmd="fluxwrap /path/to/sim.x", precedent="fluxwrap" @@ -427,9 +430,12 @@ def fake_from_command(command, **kwargs): try: with ( - mock.patch.object(flux_executor.JobspecV1, "from_command", side_effect=fake_from_command), + mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, + mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 # noqa: F841 + mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 mock.patch.object(flux_executor.flux.job, "submit", return_value=42), ): + _patched_jobspecV1.from_command.side_effect = fake_from_command task = executor.submit(app_name="sim", num_procs=4, num_nodes=2, num_gpus=4, app_args="--flag value") finally: if old_env is None: @@ -444,11 +450,241 @@ def fake_from_command(command, **kwargs): assert kwargs["num_nodes"] == 2 assert kwargs["gpus_per_task"] == 1 assert kwargs["environment"]["TEST_FLUX_ENV"] == "present" - assert kwargs["environment"]["LIBENSEMBLE_SIM_DIR"] == "." + # Environment passed through to Jobspec should include current process env. + assert "LIBENSEMBLE_SIM_DIR" not in kwargs["environment"] or isinstance( + kwargs["environment"].get("LIBENSEMBLE_SIM_DIR"), str + ) jobspec.setattr_shell_option.assert_called_once_with("gpu-affinity", "per-task") assert task.flux_jobid == 42 +def test_flux_executor_submit_dry_run_marks_task_complete_and_does_not_submit_job(): + """Dry-run should not call JobspecV1.from_command or flux.job.submit.""" + executor = _make_uninitialized_flux_executor(worker_id=7) + + app = SimpleNamespace( + name="sim", + full_path="/path/to/sim.x", + app_cmd="fluxwrap /path/to/sim.x", + precedent="fluxwrap", + ) + + executor.get_app = lambda app_name: app + executor.default_app = lambda calc_type: app + executor._check_app_exists = mock.Mock() + + mock_from_command = mock.Mock() + + with ( + mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, + mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 + mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 + mock.patch.object(flux_executor.flux.job, "submit") as mock_submit, + ): + _patched_jobspecV1.from_command = mock_from_command + task = executor.submit( + app_name="sim", + num_procs=2, + num_nodes=1, + num_gpus=0, + app_args="--flag", + stdout="out.txt", + stderr="err.txt", + dry_run=True, + ) + + mock_from_command.assert_not_called() + mock_submit.assert_not_called() + executor._check_app_exists.assert_not_called() + assert task.finished is True + assert task.state == "FINISHED" + assert task.success is True + assert task.runline is not None + assert len(executor.list_of_tasks) == 1 + + +def test_flux_executor_submit_wait_on_start_invokes_waiter(): + """wait_on_start should call FluxExecutor._wait_on_start when not in dry_run.""" + executor = _make_uninitialized_flux_executor(worker_id=7) + + app = SimpleNamespace( + name="sim", + full_path="/path/to/sim.x", + app_cmd="fluxwrap /path/to/sim.x", + precedent="fluxwrap", + ) + + executor.get_app = lambda app_name: app + executor.default_app = lambda calc_type: app + executor._check_app_exists = lambda app_obj: None + + jobspec = SimpleNamespace(stdout=None, stderr=None) + + with ( + mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, + mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 + mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 + mock.patch.object(flux_executor.flux.job, "submit", return_value=123), + mock.patch.object(executor, "_wait_on_start") as mock_wait_on_start, + ): + _patched_jobspecV1.from_command.return_value = jobspec + task = executor.submit( + app_name="sim", + num_procs=2, + num_nodes=1, + num_gpus=None, + wait_on_start=True, + ) + + mock_wait_on_start.assert_called_once_with(task) + assert task.flux_jobid == 123 + + +def test_flux_executor_submit_validation_errors(): + executor = _make_uninitialized_flux_executor(worker_id=7) + + app = SimpleNamespace( + name="sim", + full_path="/path/to/sim.x", + app_cmd="fluxwrap /path/to/sim.x", + precedent="fluxwrap", + ) + + executor.get_app = lambda app_name: app + executor.default_app = lambda calc_type: app + executor._check_app_exists = lambda app_obj: None + + # Missing app_name + calc_type + with pytest.raises(Exception): + executor.submit() + + # extra_args unsupported + with pytest.raises(Exception, match="extra_args"): + executor.submit(app_name="sim", num_procs=1, num_nodes=1, extra_args="--x") + + # num_gpus negative + with pytest.raises(Exception, match="num_gpus must be non-negative"): + executor.submit(app_name="sim", num_procs=2, num_nodes=1, num_gpus=-1) + + # num_gpus not divisible by num_procs + with pytest.raises(Exception, match="num_gpus must be divisible by num_procs"): + executor.submit(app_name="sim", num_procs=3, num_nodes=1, num_gpus=2) + + # procs_per_node divides num_procs -> num_nodes inferred + with ( + mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, + mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 + mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 + mock.patch.object(flux_executor.flux.job, "submit", return_value=1), + ): + _patched_jobspecV1.from_command.return_value = SimpleNamespace(stdout=None, stderr=None) + executor.submit(app_name="sim", num_procs=4, procs_per_node=2) + + # num_procs must be divisible by procs_per_node + with pytest.raises(Exception, match="divisible by procs_per_node"): + executor.submit(app_name="sim", num_procs=3, procs_per_node=2) + + # num_procs must equal num_nodes * procs_per_node + with pytest.raises(Exception, match=r"num_procs must equal num_nodes \* procs_per_node"): + executor.submit(app_name="sim", num_procs=5, num_nodes=2, procs_per_node=3) + + +def test_flux_executor_submit_error_from_flux_job_submit_sets_failed_to_start(): + executor = _make_uninitialized_flux_executor(worker_id=7) + + app = SimpleNamespace( + name="sim", + full_path="/path/to/sim.x", + app_cmd="fluxwrap /path/to/sim.x", + precedent="fluxwrap", + ) + + executor.get_app = lambda app_name: app + executor.default_app = lambda calc_type: app + executor._check_app_exists = lambda app_obj: None + + jobspec = SimpleNamespace(stdout=None, stderr=None) + + with ( + mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, + mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 + mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 + mock.patch.object(flux_executor.flux.job, "submit", side_effect=RuntimeError("boom")), + ): + _patched_jobspecV1.from_command.return_value = jobspec + with pytest.raises(Exception, match="Failed to submit Flux job"): + executor.submit(app_name="sim", num_procs=2, num_nodes=1) + + # Submit failed; the task is created, but it may or may not be appended + # depending on where the exception is raised. Ensure the exception type is correct. + assert executor.list_of_tasks == [] + + +def test_flux_executor_submit_stdout_stderr_are_placed_under_workdir(): + executor = _make_uninitialized_flux_executor(worker_id=7) + + app = SimpleNamespace( + name="sim", + full_path="/path/to/sim.x", + app_cmd="fluxwrap /path/to/sim.x", + precedent="fluxwrap", + ) + + executor.get_app = lambda app_name: app + executor.default_app = lambda calc_type: app + executor._check_app_exists = lambda app_obj: None + + jobspec = SimpleNamespace(stdout=None, stderr=None) + + with ( + mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, + mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 + mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 + mock.patch.object(flux_executor.flux.job, "submit", return_value=1), + ): + _patched_jobspecV1.from_command.return_value = jobspec + task = executor.submit( + app_name="sim", + num_procs=2, + num_nodes=1, + stdout="my_stdout.txt", + stderr="my_stderr.txt", + ) + + assert task.flux_jobid == 1 + assert jobspec.stdout.endswith(os.path.join(task.workdir, "my_stdout.txt")) + assert jobspec.stderr.endswith(os.path.join(task.workdir, "my_stderr.txt")) + + +def test_flux_executor_submit_sets_gpu_affinity_only_when_num_gpus_nonzero(): + executor = _make_uninitialized_flux_executor(worker_id=7) + + app = SimpleNamespace( + name="sim", + full_path="/path/to/sim.x", + app_cmd="fluxwrap /path/to/sim.x", + precedent="fluxwrap", + ) + + executor.get_app = lambda app_name: app + executor.default_app = lambda calc_type: app + executor._check_app_exists = lambda app_obj: None + + jobspec = SimpleNamespace(stdout=None, stderr=None) + jobspec.setattr_shell_option = mock.Mock() + + with ( + mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, + mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 + mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 + mock.patch.object(flux_executor.flux.job, "submit", return_value=1), + ): + _patched_jobspecV1.from_command.return_value = jobspec + executor.submit(app_name="sim", num_procs=4, num_nodes=1, num_gpus=0) + + jobspec.setattr_shell_option.assert_not_called() + + def test_flux_executor_init_connects_with_flux_uri(): """Test FluxExecutor initializes when Flux bindings and FLUX_URI are available.""" fake_flux_module = SimpleNamespace(Flux=mock.Mock(return_value="flux-handle")) From 0f92d54c0472627b3c3d9301defa24b2b2094ad2 Mon Sep 17 00:00:00 2001 From: jlnav Date: Thu, 23 Jul 2026 10:35:32 -0500 Subject: [PATCH 18/18] FluxExecutor appends/respects existing environment. safeguards and messages about FluxExecutor's internal FluxState not being thread-safe, so process-based only. set more expected attributes upon Flux results. use Flux's submit_async instead for non-blocking. don't wait too long to kill a flux job. Specify flux uri optionally to FluxExecutor init in case the instance isn't obvious. passthrough task.stdout and task.stderr to JobSpecV1. test adjustments --- libensemble/executors/flux_executor.py | 156 +++++++++++++--------- libensemble/tests/unit_tests/test_flux.py | 122 ++++++++++------- 2 files changed, 172 insertions(+), 106 deletions(-) diff --git a/libensemble/executors/flux_executor.py b/libensemble/executors/flux_executor.py index ea3163ae4..8185c9ae8 100644 --- a/libensemble/executors/flux_executor.py +++ b/libensemble/executors/flux_executor.py @@ -19,7 +19,13 @@ Requirements: - flux-core Python bindings must be installed - - Must be running inside a Flux instance (FLUX_URI must be set) + - Must be running inside a Flux instance or provide a Flux URI + +Notes: + Flux handles are not thread-safe. FluxExecutor is best suited for + process-based libEnsemble runs, such as multiprocessing or MPI workers. + The executor reconnects lazily after process serialization so each worker + process uses its own Flux handle. """ import logging @@ -127,19 +133,29 @@ def _handle_completion(self, info: dict) -> None: self.finished = True self.calc_task_timing() - # Check result/exit status result = str(info.get("result", "")).upper() - success = result == "COMPLETED" or info.get("returncode", 1) == 0 + self.errcode = info.get("returncode", 1) + self.success = result == "COMPLETED" or self.errcode == 0 + self.state = "FINISHED" if self.success else "FAILED" + if self.success: + self.errcode = 0 - if success: - self.success = True - self.state = "FINISHED" + logger.info(f"Task {self.name} finished with state {self.state} (result={result})") + + def _handle_result(self, info) -> None: + """Handle a terminal Flux JobInfo object returned by flux.job.result().""" + result = str(getattr(info, "result", "")).upper() + returncode = getattr(info, "returncode", 1) + if returncode == "": + returncode = 0 if result == "COMPLETED" else 1 + + self.errcode = returncode + self.finished = True + self.calc_task_timing() + self.success = result == "COMPLETED" or self.errcode == 0 + self.state = "FINISHED" if self.success else "FAILED" + if self.success: self.errcode = 0 - else: - self.success = False - self.state = "FAILED" - # Try to get exit code from result - self.errcode = info.get("returncode", 1) logger.info(f"Task {self.name} finished with state {self.state} (result={result})") @@ -166,19 +182,26 @@ def wait(self, timeout: float | None = None) -> None: if not self._check_poll(): return - # Wait for job to complete - start_time = time.time() - while True: - self.poll() - if self.finished: - break + if timeout is not None: + start_time = time.time() + while True: + self.poll() + if self.finished: + return - if timeout is not None: - elapsed = time.time() - start_time - if elapsed >= timeout: + if time.time() - start_time >= timeout: raise TimeoutExpired(self.name, timeout) - time.sleep(0.1) + time.sleep(0.1) + + try: + info = flux.job.result(self.flux_handle, self.flux_jobid) + self._handle_result(info) + except Exception as e: + logger.warning(f"Error waiting for Flux job {self.flux_jobid}: {e}") + self.state = "UNKNOWN" + self.runtime = self.timer.elapsed + raise def kill(self, wait_time: int | None = 60) -> None: """Kills/cancels the Flux job. @@ -200,23 +223,22 @@ def kill(self, wait_time: int | None = 60) -> None: logger.info(f"Canceling Flux job {self.flux_jobid} for task {self.name}") try: - # Cancel the job using Flux API flux.job.cancel(self.flux_handle, self.flux_jobid) - - # Wait briefly for cancellation to take effect - if wait_time: - deadline = time.time() + min(wait_time, 5) # Don't wait too long - while time.time() < deadline: - self.poll() - if self.finished: - break - time.sleep(0.1) - except Exception as e: logger.warning(f"Error canceling Flux job {self.flux_jobid}: {e}") + return + + if wait_time: + deadline = time.time() + min(wait_time, 5) # Don't wait too long + while time.time() < deadline: + self.poll() + if self.finished: + break + time.sleep(0.1) self.state = "USER_KILLED" self.finished = True + self.success = False self.calc_task_timing() @@ -230,12 +252,14 @@ class FluxExecutor(Executor): Parameters ---------- - None + uri: str, Optional + Flux instance URI. If omitted, ``flux.Flux()`` connects to the nearest + enclosing Flux instance discovered by Flux. Raises ------ ExecutorException - If flux Python bindings are not available or FLUX_URI is not set. + If flux Python bindings are not available or connecting to Flux fails. Example ------- @@ -251,7 +275,7 @@ class FluxExecutor(Executor): task.wait() """ - def __init__(self) -> None: + def __init__(self, uri: str | None = None) -> None: """Instantiate a new FluxExecutor instance.""" if not FLUX_AVAILABLE: raise ExecutorException( @@ -259,21 +283,27 @@ def __init__(self) -> None: "Install flux-core or use MPIExecutor with mpi_runner='flux' instead." ) - if not os.environ.get("FLUX_URI"): - raise ExecutorException( - "FLUX_URI environment variable not set. " "FluxExecutor must be used inside a Flux instance." - ) - super().__init__() - # Connect to the Flux instance - try: - self.flux_handle = flux.Flux() - except Exception as e: - raise ExecutorException(f"Failed to connect to Flux instance: {e}") - self.resources = None self.platform_info: dict = {} + self.uri = uri + self.flux_handle = None + + def __getstate__(self): + """Avoid sharing non-thread-safe Flux handles across worker processes.""" + state = self.__dict__.copy() + state["flux_handle"] = None + return state + + def _get_flux_handle(self): + """Return a Flux handle for this process, opening it lazily if needed.""" + if self.flux_handle is None: + try: + self.flux_handle = flux.Flux(self.uri) if self.uri is not None else flux.Flux() + except Exception as e: + raise ExecutorException(f"Failed to connect to Flux instance: {e}") + return self.flux_handle def submit( self, @@ -287,7 +317,7 @@ def submit( stdout: str | None = None, stderr: str | None = None, dry_run: bool = False, - wait_on_start: bool = False, + wait_on_start: bool | int = False, extra_args: str | None = None, ) -> FluxTask: """Submit a job to Flux. @@ -326,8 +356,9 @@ def submit( dry_run: bool, Optional If True, don't actually submit the job - wait_on_start: bool, Optional - Whether to wait for job to start running + wait_on_start: bool or int, Optional + Whether to wait for job to start running. If an integer N is supplied, + wait at most N seconds. extra_args: str, Optional Additional arguments (currently not used for native Flux) @@ -349,7 +380,8 @@ def submit( default_workdir = os.getcwd() task = FluxTask(app, app_args, default_workdir, stdout, stderr, self.workerID, dry_run) - task.flux_handle = self.flux_handle + if not dry_run: + task.flux_handle = self._get_flux_handle() if not dry_run: self._check_app_exists(task.app) @@ -363,8 +395,6 @@ def submit( if num_procs % procs_per_node != 0: raise ExecutorException("num_procs must be divisible by procs_per_node for FluxExecutor") num_nodes = num_procs // procs_per_node - else: - num_nodes = 1 elif procs_per_node is not None and num_procs != num_nodes * procs_per_node: raise ExecutorException("num_procs must equal num_nodes * procs_per_node for FluxExecutor") @@ -390,6 +420,9 @@ def submit( raise ExecutorException("num_gpus must be divisible by num_procs for FluxExecutor") gpus_per_task = num_gpus // num_procs if num_gpus else 0 + environment = dict(os.environ) + environment.update(task.env) + jobspec = JobspecV1.from_command( command, num_tasks=num_procs, @@ -397,25 +430,28 @@ def submit( cores_per_task=1, gpus_per_task=gpus_per_task, cwd=task.workdir, - environment=dict(os.environ), + environment=environment, + output=os.path.join(task.workdir, task.stdout), + error=os.path.join(task.workdir, task.stderr), ) - - if stdout: - jobspec.stdout = os.path.join(task.workdir, stdout) - if stderr: - jobspec.stderr = os.path.join(task.workdir, stderr) if gpus_per_task: jobspec.setattr_shell_option("gpu-affinity", "per-task") logger.info(f"Submitting Flux job for task {task.name}: {task.runline}") - task.flux_jobid = flux.job.submit(self.flux_handle, jobspec) + task.flux_future = flux.job.submit_async(task.flux_handle, jobspec) + task.flux_jobid = task.flux_future.get_id() if task.flux_future else None logger.info(f"Task {task.name} submitted with Flux job ID {task.flux_jobid}") task.timer.start() task.submit_time = task.timer.tstart if wait_on_start: - self._wait_on_start(task) + timeout = ( + wait_on_start + if isinstance(wait_on_start, int) and not isinstance(wait_on_start, bool) + else 60.0 + ) + self._wait_on_start(task, timeout) except Exception as e: logger.error(f"Failed to submit Flux job: {e}") diff --git a/libensemble/tests/unit_tests/test_flux.py b/libensemble/tests/unit_tests/test_flux.py index cbfc67392..9d624c5b3 100644 --- a/libensemble/tests/unit_tests/test_flux.py +++ b/libensemble/tests/unit_tests/test_flux.py @@ -339,27 +339,24 @@ def test_flux_executor_import_without_flux(): pytest.skip("flux_executor module not available") -def test_flux_executor_requires_flux_uri(): - """Test FluxExecutor raises error when FLUX_URI not set""" +def test_flux_executor_connects_lazily_with_default_or_explicit_uri(): + """FluxExecutor should defer Flux connection and support explicit URIs.""" try: from libensemble.executors.flux_executor import FLUX_AVAILABLE, FluxExecutor if not FLUX_AVAILABLE: pytest.skip("Flux Python bindings not available") - # Save and clear FLUX_URI - old_uri = os.environ.get("FLUX_URI") - if "FLUX_URI" in os.environ: - del os.environ["FLUX_URI"] - - try: - from libensemble.executors.executor import ExecutorException + with mock.patch.object(flux_executor.flux, "Flux", return_value="default-handle") as mock_flux: + executor = FluxExecutor() + assert executor.flux_handle is None + assert executor._get_flux_handle() == "default-handle" + mock_flux.assert_called_once_with() - with pytest.raises(ExecutorException, match="FLUX_URI"): - FluxExecutor() - finally: - if old_uri: - os.environ["FLUX_URI"] = old_uri + with mock.patch.object(flux_executor.flux, "Flux", return_value="uri-handle") as mock_flux: + executor = FluxExecutor(uri="local:///tmp/flux-uri") + assert executor._get_flux_handle() == "uri-handle" + mock_flux.assert_called_once_with("local:///tmp/flux-uri") except ImportError: pytest.skip("flux_executor module not available") @@ -395,6 +392,7 @@ def _make_uninitialized_flux_executor(*, worker_id: int = 7): executor = object.__new__(flux_executor.FluxExecutor) executor.flux_handle = object() executor.platform_info = {} + executor.uri = None executor.workerID = worker_id executor.list_of_tasks = [] executor.apps = {} @@ -420,6 +418,7 @@ def test_flux_executor_submit_builds_jobspec_with_environment_and_gpus(): jobspec = SimpleNamespace(stdout=None, stderr=None) submit_calls = [] + submit_future = SimpleNamespace(get_id=mock.Mock(return_value=42)) def fake_from_command(command, **kwargs): submit_calls.append((command, kwargs)) @@ -431,9 +430,9 @@ def fake_from_command(command, **kwargs): try: with ( mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, - mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 # noqa: F841 + mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 - mock.patch.object(flux_executor.flux.job, "submit", return_value=42), + mock.patch.object(flux_executor.flux.job, "submit_async", return_value=submit_future) as mock_submit_async, ): _patched_jobspecV1.from_command.side_effect = fake_from_command task = executor.submit(app_name="sim", num_procs=4, num_nodes=2, num_gpus=4, app_args="--flag value") @@ -450,16 +449,18 @@ def fake_from_command(command, **kwargs): assert kwargs["num_nodes"] == 2 assert kwargs["gpus_per_task"] == 1 assert kwargs["environment"]["TEST_FLUX_ENV"] == "present" - # Environment passed through to Jobspec should include current process env. - assert "LIBENSEMBLE_SIM_DIR" not in kwargs["environment"] or isinstance( - kwargs["environment"].get("LIBENSEMBLE_SIM_DIR"), str - ) + assert kwargs["environment"]["LIBENSEMBLE_SIM_DIR"] == "." + assert kwargs["output"] == os.path.join(task.workdir, task.stdout) + assert kwargs["error"] == os.path.join(task.workdir, task.stderr) + mock_submit_async.assert_called_once_with(executor.flux_handle, jobspec) + submit_future.get_id.assert_called_once_with() jobspec.setattr_shell_option.assert_called_once_with("gpu-affinity", "per-task") assert task.flux_jobid == 42 + assert task.flux_future is submit_future def test_flux_executor_submit_dry_run_marks_task_complete_and_does_not_submit_job(): - """Dry-run should not call JobspecV1.from_command or flux.job.submit.""" + """Dry-run should not call JobspecV1.from_command or flux.job.submit_async.""" executor = _make_uninitialized_flux_executor(worker_id=7) app = SimpleNamespace( @@ -479,7 +480,7 @@ def test_flux_executor_submit_dry_run_marks_task_complete_and_does_not_submit_jo mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 - mock.patch.object(flux_executor.flux.job, "submit") as mock_submit, + mock.patch.object(flux_executor.flux.job, "submit_async") as mock_submit, ): _patched_jobspecV1.from_command = mock_from_command task = executor.submit( @@ -519,12 +520,13 @@ def test_flux_executor_submit_wait_on_start_invokes_waiter(): executor._check_app_exists = lambda app_obj: None jobspec = SimpleNamespace(stdout=None, stderr=None) + submit_future = SimpleNamespace(get_id=mock.Mock(return_value=123)) with ( mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 - mock.patch.object(flux_executor.flux.job, "submit", return_value=123), + mock.patch.object(flux_executor.flux.job, "submit_async", return_value=submit_future), mock.patch.object(executor, "_wait_on_start") as mock_wait_on_start, ): _patched_jobspecV1.from_command.return_value = jobspec @@ -533,10 +535,10 @@ def test_flux_executor_submit_wait_on_start_invokes_waiter(): num_procs=2, num_nodes=1, num_gpus=None, - wait_on_start=True, + wait_on_start=7, ) - mock_wait_on_start.assert_called_once_with(task) + mock_wait_on_start.assert_called_once_with(task, 7) assert task.flux_jobid == 123 @@ -575,7 +577,9 @@ def test_flux_executor_submit_validation_errors(): mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 - mock.patch.object(flux_executor.flux.job, "submit", return_value=1), + mock.patch.object( + flux_executor.flux.job, "submit_async", return_value=SimpleNamespace(get_id=mock.Mock(return_value=1)) + ), ): _patched_jobspecV1.from_command.return_value = SimpleNamespace(stdout=None, stderr=None) executor.submit(app_name="sim", num_procs=4, procs_per_node=2) @@ -609,7 +613,7 @@ def test_flux_executor_submit_error_from_flux_job_submit_sets_failed_to_start(): mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 - mock.patch.object(flux_executor.flux.job, "submit", side_effect=RuntimeError("boom")), + mock.patch.object(flux_executor.flux.job, "submit_async", side_effect=RuntimeError("boom")), ): _patched_jobspecV1.from_command.return_value = jobspec with pytest.raises(Exception, match="Failed to submit Flux job"): @@ -634,15 +638,21 @@ def test_flux_executor_submit_stdout_stderr_are_placed_under_workdir(): executor.default_app = lambda calc_type: app executor._check_app_exists = lambda app_obj: None - jobspec = SimpleNamespace(stdout=None, stderr=None) + submit_calls = [] + + def fake_from_command(command, **kwargs): + submit_calls.append(kwargs) + return SimpleNamespace(stdout=None, stderr=None) with ( mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 - mock.patch.object(flux_executor.flux.job, "submit", return_value=1), + mock.patch.object( + flux_executor.flux.job, "submit_async", return_value=SimpleNamespace(get_id=mock.Mock(return_value=1)) + ), ): - _patched_jobspecV1.from_command.return_value = jobspec + _patched_jobspecV1.from_command.side_effect = fake_from_command task = executor.submit( app_name="sim", num_procs=2, @@ -652,8 +662,8 @@ def test_flux_executor_submit_stdout_stderr_are_placed_under_workdir(): ) assert task.flux_jobid == 1 - assert jobspec.stdout.endswith(os.path.join(task.workdir, "my_stdout.txt")) - assert jobspec.stderr.endswith(os.path.join(task.workdir, "my_stderr.txt")) + assert submit_calls[0]["output"].endswith(os.path.join(task.workdir, "my_stdout.txt")) + assert submit_calls[0]["error"].endswith(os.path.join(task.workdir, "my_stderr.txt")) def test_flux_executor_submit_sets_gpu_affinity_only_when_num_gpus_nonzero(): @@ -677,7 +687,9 @@ def test_flux_executor_submit_sets_gpu_affinity_only_when_num_gpus_nonzero(): mock.patch.object(flux_executor, "JobspecV1", create=True, autospec=False) as _patched_jobspecV1, mock.patch.object(flux_executor, "flux", create=True) as _patched_flux, # noqa: F841 mock.patch.object(flux_executor.flux, "job", create=True) as _patched_job, # noqa: F841 - mock.patch.object(flux_executor.flux.job, "submit", return_value=1), + mock.patch.object( + flux_executor.flux.job, "submit_async", return_value=SimpleNamespace(get_id=mock.Mock(return_value=1)) + ), ): _patched_jobspecV1.from_command.return_value = jobspec executor.submit(app_name="sim", num_procs=4, num_nodes=1, num_gpus=0) @@ -685,20 +697,15 @@ def test_flux_executor_submit_sets_gpu_affinity_only_when_num_gpus_nonzero(): jobspec.setattr_shell_option.assert_not_called() -def test_flux_executor_init_connects_with_flux_uri(): - """Test FluxExecutor initializes when Flux bindings and FLUX_URI are available.""" - fake_flux_module = SimpleNamespace(Flux=mock.Mock(return_value="flux-handle")) - - with ( - mock.patch.object(flux_executor, "FLUX_AVAILABLE", True), - mock.patch.object(flux_executor, "flux", fake_flux_module), - mock.patch.dict(os.environ, {"FLUX_URI": "local:///tmp/flux-test"}, clear=False), - ): - executor = flux_executor.FluxExecutor() +def test_flux_executor_getstate_drops_flux_handle(): + """FluxExecutor should not serialize an open Flux handle into worker processes.""" + with mock.patch.object(flux_executor, "FLUX_AVAILABLE", True): + executor = flux_executor.FluxExecutor(uri="local:///tmp/flux-test") - fake_flux_module.Flux.assert_called_once_with() + executor.flux_handle = "flux-handle" + state = executor.__getstate__() + assert state["flux_handle"] is None assert executor.flux_handle == "flux-handle" - assert executor.platform_info == {} def test_flux_executor_wait_on_start_polls_until_running(): @@ -910,6 +917,29 @@ def complete_on_second_poll(): assert task.state == "FINISHED" assert task.poll.call_count == 2 + result_task = flux_executor.FluxTask( + app=SimpleNamespace(name="app"), + app_args=None, + workdir=os.getcwd(), + stdout="out.txt", + stderr="err.txt", + workerid=1, + dry_run=False, + ) + result_task.flux_handle = object() + result_task.flux_jobid = 321 + result_task.timer.start() + result_task.submit_time = result_task.timer.tstart + result_info = SimpleNamespace(result="COMPLETED", returncode=0) + fake_flux = SimpleNamespace(job=SimpleNamespace(result=mock.Mock(return_value=result_info))) + + with mock.patch.object(flux_executor, "flux", fake_flux): + result_task.wait() + + fake_flux.job.result.assert_called_once_with(result_task.flux_handle, result_task.flux_jobid) + assert result_task.finished is True + assert result_task.state == "FINISHED" + timeout_task = flux_executor.FluxTask( app=SimpleNamespace(name="app"), app_args=None, @@ -1006,7 +1036,7 @@ def poll_side_effect(): test_env_resources_flux_env_variable() # Flux Executor tests - test_flux_executor_init_connects_with_flux_uri() + test_flux_executor_getstate_drops_flux_handle() test_flux_executor_wait_on_start_polls_until_running() test_flux_task_poll_maps_completion_waiting_and_unknown_states() test_flux_task_handle_completion_success_and_failure()