Skip to content

Commit 6b0f5eb

Browse files
committed
deepcopy and global seeding changes
- add note not to use deepcopy with globally seeded object - enable testing of temp constraints on global seed repeat
1 parent 1fc2af7 commit 6b0f5eb

File tree

2 files changed

+90
-20
lines changed

2 files changed

+90
-20
lines changed

docs/howto.rst

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -167,6 +167,59 @@ The ``RandObj`` class accepts an instance of the ``random.Random`` class in its
167167
# Using a different seed produces different results.
168168
my_function(1, "third call:")
169169
170+
Copying and repeatability
171+
_________________________
172+
173+
``RandObj`` supports Python's ``copy.deepcopy``. However, there is a caveat that users should be aware of.
174+
175+
A ``RandObj`` that is seeded with the object-based approach above is guaranteed to be repeatable from the point where it is copied.
176+
177+
E.g. the following code will run fine with no assertion error:
178+
179+
.. code-block:: python
180+
181+
import copy
182+
import random
183+
184+
from constrainedrandom import RandObj
185+
186+
randobj = RandObj(random.Random(0))
187+
randobj.add_rand_var('x', domain=range(100))
188+
randobj_copy = copy.deepcopy(randobj)
189+
randobj.randomize()
190+
randobj_copy.randomize()
191+
# The results of both will be the same.
192+
assert randobj.get_results() == randobj_copy.get_results(), "this should be true!"
193+
194+
However, a ``RandObj`` that is globally seeded is not suitable to be deep copied. A strange interaction between ``deepcopy`` and ``functools.partial`` that depend on the globally-scoped ``random`` module means that state is unintentionally shared between the original ``RandObj`` and its copy.
195+
196+
E.g. the following code will *NOT* give reproducible results between the original object and its copy.
197+
198+
.. code-block:: python
199+
200+
import copy
201+
import random
202+
203+
from constrainedrandom import RandObj
204+
205+
random.seed(0)
206+
randobj = RandObj()
207+
randobj.add_rand_var('x', domain=range(100))
208+
# Take a copy
209+
randobj_copy = copy.deepcopy(randobj)
210+
randobj.randomize()
211+
# re-seed global random to try to make it reproducible
212+
random.seed(0)
213+
randobj_copy.randomize()
214+
# The following will fail for many RandObj instances:
215+
assert randobj.get_results() == randobj_copy.get_results(), "this will fail!"
216+
217+
There is another issue where constraints added to the original may affect the copy, and vice versa. This is obviously not a good thing.
218+
219+
Therefore, **the user is recommended not to use ``copy.deepcopy`` with globally-seeded instances of ``RandObj``**.
220+
221+
To fix this issue, ``RandObj`` would need to implement its own ``__deepcopy__`` method. This can be done as and when this behaviour is required by users and the workaround of using object-based seeding is not acceptable.
222+
170223
Adding random variables
171224
-----------------------
172225

tests/testutils.py

Lines changed: 37 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -17,8 +17,8 @@ def assertListOfDictsEqual(instance, list0, list1, msg) -> None:
1717
This is missing from unittest. It doesn't like it when two large lists of dictionaries
1818
are different and compared using `assertListEqual` or `assertEqual`.
1919
'''
20-
for i, j in zip(list0, list1):
21-
instance.assertDictEqual(i, j, msg)
20+
for idx, (i, j) in enumerate(zip(list0, list1)):
21+
instance.assertDictEqual(i, j, f"iteration {idx} failed: " + msg)
2222

2323

2424
class RandObjTestBase(unittest.TestCase):
@@ -231,10 +231,9 @@ def test_randobj(self) -> None:
231231
"Results were the same for two different seeds, check testcase.")
232232

233233
# Test using global seeding, ensuring results are the same
234+
# Don't add temp constraints this time, so that we can test this object again.
234235
random.seed(0)
235236
randobj0_global = self.get_randobj()
236-
# Take a copy for later
237-
randobj0_global_copy = deepcopy(randobj0_global)
238237
self.randomize_and_check_result(
239238
randobj0_global,
240239
results,
@@ -248,7 +247,7 @@ def test_randobj(self) -> None:
248247
)
249248

250249
# Re-test the the globally-seeded object
251-
# Must re-seed the global random package to ensure repeatability
250+
# Must re-seed the global random module to ensure repeatability.
252251
random.seed(0)
253252
self.randomize_and_check_result(
254253
randobj0_global,
@@ -259,20 +258,38 @@ def test_randobj(self) -> None:
259258
tmp_results,
260259
post_tmp_results,
261260
add_results,
262-
add_tmp_constraints=False,
261+
add_tmp_constraints=True,
263262
)
264263

265-
# Test the copied globally-seeded object
266-
# Must re-seed the global random package to ensure repeatability
267-
random.seed(0)
268-
self.randomize_and_check_result(
269-
randobj0_global_copy,
270-
results,
271-
do_tmp_checks,
272-
tmp_constraints,
273-
tmp_values,
274-
tmp_results,
275-
post_tmp_results,
276-
add_results,
277-
add_tmp_constraints=False,
278-
)
264+
# TODO: Fix interaction between global random module and deepcopy.
265+
# Details:
266+
# There is an issue around copying an object that relies on the
267+
# global random object - the state of any copied object is tied to
268+
# its original.
269+
# Having spent a lot of time debugging this issue, it is still very
270+
# difficult to understand.
271+
# Each individual copied RandObj instance points to a new random.Random
272+
# instance, which shares state with the global module. It appears then
273+
# in some instances that the object uses the global value in the random
274+
# module, and in others it uses the copied one, meaning the state
275+
# diverges.
276+
# Right now, all I can conclude is it would take a lot of work to
277+
# fully debug it, and it can be worked around by passing objects a
278+
# seeded random.Random if the user desires reproducible objects.
279+
280+
# TODO: Make testing of copy more thorough when above issue fixed.
281+
# Take a copy, to show that we can. Its behaviour can't be guaranteed
282+
# to be deterministic w.r.t. randobj0_global due to issues around
283+
# deepcopy interacting with the global random module.
284+
# So, just test it can randomize.
285+
randobj0_global_copy = deepcopy(randobj0_global)
286+
if self.EXPECT_FAILURE:
287+
self.assertRaises(RandomizationError, randobj0_global_copy.randomize)
288+
else:
289+
# Don't check results. Checks may fail due to the interaction
290+
# between deepcopy and global random. E.g. if we check that temp
291+
# constraints are not followed when not supplied, they may
292+
# be due to the interaction between random and deepcopy.
293+
# This just ensures it doesn't crash.
294+
self.randomize_and_time(randobj0_global_copy, self.iterations)
295+
self.randomize_and_time(randobj0_global_copy, self.iterations, tmp_constraints, tmp_values)

0 commit comments

Comments
 (0)