From 45656c2a0a86a254c96c3659af6571ecc1880137 Mon Sep 17 00:00:00 2001 From: WojtAcht Date: Wed, 14 Oct 2026 18:07:35 +0200 Subject: [PATCH] fix: stop get_layout from mutating global NumPy and igraph RNG state (#53) --- CHANGELOG.md | 1 + src/lonkit/visualization.py | 11 +++++++---- tests/test_visualization.py | 32 ++++++++++++++++++++++++++++++++ 3 files changed, 40 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 67af694..08fbfd5 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,7 @@ ### Bug Fixes +- `LONVisualizer.get_layout()` with a `seed`, and therefore `plot_2d()`, `plot_3d()`, `create_rotation_gif()` and `visualize_all()`, no longer reseeds the global NumPy RNG or leaves a seeded RNG installed in igraph. The seed now applies only to the layout, and igraph's default RNG is restored afterwards. Seeded layouts are unchanged (#53). - Continuous sampling now handles failed local searches explicitly. A local search fails when `minimize` raises `ValueError` or returns a non-finite fitness. A failed initial local search fails its run, and a failed search after a perturbation is rejected and counts as a non-improving iteration. Previously, runs starting at a `nan` minimum silently vanished, and an `inf` minimum made `LON.from_trace_data()` fail after all sampling had finished. Results with `success=False` and a finite fitness are still used as local optima. Each failed local search emits a warning with its context and reason (#44). - `StepSizeEstimator` divides each sample's escape count by its number of evaluated perturbations instead of `n_perturbations`, so failed local searches no longer bias the escape rate down. Samples whose initial local search fails are skipped, and `RuntimeError` is raised if no sample can be evaluated, instead of returning `nan` (#44). - Runs without accepted transitions are no longer dropped from the LON and its performance metrics. `BasinHoppingSampler` and `ILSSampler` now record such a run as a single trace row from its initial local optimum to itself. `LON.from_trace_data()` drops self-loops, so the optimum becomes a vertex without edges and the run counts towards `success` and `deviation`. Basin-Hopping runs with a non-finite initial minimum are still skipped. Seeded traces and LONs change only when some run had no accepted transitions. Set `preserve_runs_without_transitions=False` in either sampler config to restore the earlier behavior, excluding these runs from both the graph and performance metrics (#39). diff --git a/src/lonkit/visualization.py b/src/lonkit/visualization.py index d144805..ee6159f 100644 --- a/src/lonkit/visualization.py +++ b/src/lonkit/visualization.py @@ -148,10 +148,13 @@ def get_layout(self, graph, seed: int | None = None) -> np.ndarray: """Get 2D layout coordinates, with shape ``(0, 2)`` for an empty graph.""" if graph.vcount() == 0: return np.empty((0, 2)) - if seed is not None: - np.random.seed(seed) - ig.set_random_number_generator(random.Random(seed)) - layout = graph.layout_auto() + if seed is None: + return np.array(graph.layout_auto().coords) + ig.set_random_number_generator(random.Random(seed)) + try: + layout = graph.layout_auto() + finally: + ig.set_random_number_generator(random) return np.array(layout.coords) def plot_2d( diff --git a/tests/test_visualization.py b/tests/test_visualization.py index 28456a0..75a631b 100644 --- a/tests/test_visualization.py +++ b/tests/test_visualization.py @@ -1,5 +1,7 @@ +import random from pathlib import Path +import igraph as ig import matplotlib.pyplot as plt import numpy as np import pytest @@ -28,6 +30,36 @@ def test_layout_different_seeds_give_different_coordinates( coords2 = visualizer.get_layout(rastrigin_lon.graph, seed=SEED + 1) assert not np.array_equal(coords1, coords2) + def test_layout_does_not_change_global_numpy_rng( + self, rastrigin_lon: LON, visualizer: LONVisualizer + ) -> None: + np.random.seed(123) + expected = np.random.rand(5) + + np.random.seed(123) + visualizer.get_layout(rastrigin_lon.graph, seed=SEED) + np.testing.assert_array_equal(np.random.rand(5), expected) + + def test_layout_restores_igraph_rng( + self, rastrigin_lon: LON, visualizer: LONVisualizer + ) -> None: + ig.set_random_number_generator(random) + random.seed(123) + expected = ig.Graph.Erdos_Renyi(n=50, m=100).get_edgelist() + + random.seed(123) + visualizer.get_layout(rastrigin_lon.graph, seed=SEED) + assert ig.Graph.Erdos_Renyi(n=50, m=100).get_edgelist() == expected + + def test_layout_same_seed_ignores_global_numpy_state( + self, rastrigin_lon: LON, visualizer: LONVisualizer + ) -> None: + np.random.seed(1) + coords1 = visualizer.get_layout(rastrigin_lon.graph, seed=SEED) + np.random.seed(2) + coords2 = visualizer.get_layout(rastrigin_lon.graph, seed=SEED) + np.testing.assert_array_equal(coords1, coords2) + class TestPlot2DReproducibility: def test_plot_2d_same_seed_same_figure(