From c773271bc7f7e0af3ef5deac952bd555dd7982c1 Mon Sep 17 00:00:00 2001 From: WojtAcht Date: Thu, 15 Oct 2026 09:38:16 +0200 Subject: [PATCH] fix: validate compute_lon dimensions and domain bounds --- src/lonkit/continuous/sampling.py | 12 ++++++++ tests/continuous/test_sampling.py | 48 ++++++++++++++++++++++++++++++- 2 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/lonkit/continuous/sampling.py b/src/lonkit/continuous/sampling.py index 52f6fb9..ec828b4 100644 --- a/src/lonkit/continuous/sampling.py +++ b/src/lonkit/continuous/sampling.py @@ -745,6 +745,9 @@ def compute_lon( >>> lon = compute_lon(sphere, dim=5, lower_bound=-5.0, upper_bound=5.0) >>> print(f"Found {lon.n_vertices} local optima") """ + if isinstance(dim, bool) or not isinstance(dim, int | np.integer) or dim <= 0: + raise ValueError("dim must be a positive integer.") + # Convert scalars to lists, leave sequences as-is lower_bounds: Sequence[float] = ( [lower_bound] * dim if isinstance(lower_bound, int | float) else lower_bound @@ -753,6 +756,15 @@ def compute_lon( [upper_bound] * dim if isinstance(upper_bound, int | float) else upper_bound ) + lower_array = np.asarray(lower_bounds, dtype=float) + upper_array = np.asarray(upper_bounds, dtype=float) + if lower_array.shape != (dim,) or upper_array.shape != (dim,): + raise ValueError("lower_bound and upper_bound must each have exactly dim values.") + if not np.isfinite(lower_array).all() or not np.isfinite(upper_array).all(): + raise ValueError("Domain bounds must be finite.") + if np.any(lower_array > upper_array): + raise ValueError("lower_bound must be <= upper_bound in every dimension.") + domain = list(zip(lower_bounds, upper_bounds, strict=True)) sampler = BasinHoppingSampler(config) diff --git a/tests/continuous/test_sampling.py b/tests/continuous/test_sampling.py index 528f853..d3e1e49 100644 --- a/tests/continuous/test_sampling.py +++ b/tests/continuous/test_sampling.py @@ -5,10 +5,56 @@ import pytest from scipy.optimize import OptimizeResult -from lonkit import BasinHoppingSampler, BasinHoppingSamplerConfig +from lonkit import BasinHoppingSampler, BasinHoppingSamplerConfig, compute_lon from tests.conftest import DOMAIN_2D, SEED, sphere +class TestComputeLonDomain: + @pytest.mark.parametrize( + "dim, lower, upper, message", + [ + (0, -1, 1, "positive integer"), + (-1, -1, 1, "positive integer"), + (1.5, -1, 1, "positive integer"), + (True, -1, 1, "positive integer"), + (2, [0], [1], "exactly dim"), + (2, [0, 0], [1], "exactly dim"), + (2, [0], 1, "exactly dim"), + (2, [[0, 0]], [[1, 1]], "exactly dim"), + (2, -np.inf, 1, "finite"), + (2, 0, [1, np.inf], "finite"), + (2, [0, np.nan], 1, "finite"), + (2, [0, 2], [1, 1], "lower_bound must be <="), + ], + ) + def test_invalid_domain_fails_before_sampling(self, dim, lower, upper, message): + with patch.object(BasinHoppingSampler, "sample") as sample: + with pytest.raises(ValueError, match=message): + compute_lon(sphere, dim, lower, upper) + sample.assert_not_called() + + @pytest.mark.parametrize( + "lower, upper", + [(-1, 1), ([-1, 0], [1, 0]), (np.array([-1, -1]), 1)], + ) + def test_valid_domain_preserves_dimension(self, lower, upper): + dimensions = [] + + def objective(x): + dimensions.append(len(x)) + return sphere(x) + + lon = compute_lon( + objective, + 2, + lower, + upper, + config=BasinHoppingSamplerConfig(n_runs=1, max_iter=1, seed=SEED), + ) + assert dimensions and set(dimensions) == {2} + assert lon.n_vertices > 0 + + class TestHashSolution: @pytest.mark.parametrize("precision", [0, 1, 2, 3]) def test_small_positive_and_negative_values_hash_equally(self, precision: int) -> None: