Skip to content

Commit a2e24de

Browse files
will-keenimgtec-admin
authored andcommitted
Merge pull request #6 from imaginationtech/rand_length_basic
Basic implementation of random length for list variables * Add `rand_length` as an arg to `add_rand_var` * Basic unit tests
2 parents ff14260 + dc83a2c commit a2e24de

File tree

3 files changed

+230
-28
lines changed

3 files changed

+230
-28
lines changed

constrainedrandom/internal/randvar.py

Lines changed: 73 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -60,8 +60,11 @@ class RandVar:
6060
specified.
6161
:param constraints: List or tuple of constraints that apply to this random variable.
6262
Each of these apply across the values in the list, if a length is specified.
63-
:param length: Specify a length > 0 to turn this variable into a list of random
64-
values. A value of 0 means a scalar value. A value >= 1 means a list of that length.
63+
:param length: Specify a length >= 0 to turn this variable into a list of random
64+
values. A value >= 0 means a list of that length. A zero-length list is just
65+
an empty list. A value of ``None`` (default) means a scalar value.
66+
:param rand_length: Specify the name of a random variable that defines the length
67+
of this variable. The variable must have already been added to this instance.
6568
:param max_iterations: The maximum number of failed attempts to solve the randomization
6669
problem before giving up.
6770
:param max_domain_size: The maximum size of domain that a constraint satisfaction problem
@@ -82,7 +85,8 @@ def __init__(self,
8285
args: Optional[tuple]=None,
8386
constraints: Optional[Iterable[utils.Constraint]]=None,
8487
list_constraints: Optional[Iterable[utils.Constraint]]=None,
85-
length: int,
88+
length: Optional[int]=None,
89+
rand_length: Optional[str]=None,
8690
max_iterations: int,
8791
max_domain_size: int,
8892
disable_naive_list_solver: bool,
@@ -91,9 +95,14 @@ def __init__(self,
9195
self.name = name
9296
self.order = order
9397
self.length = length
98+
self.rand_length = rand_length
99+
self.rand_length_val = None
100+
assert not ((length is not None) and (rand_length is not None)), \
101+
"length and rand_length are mutually-exclusive, but both were specified"
94102
self.max_iterations = max_iterations
95103
self.max_domain_size = max_domain_size
96-
assert ((domain is not None) != (fn is not None)) != (bits is not None), "Must specify exactly one of fn, domain or bits"
104+
assert ((domain is not None) != (fn is not None)) != (bits is not None), \
105+
"Must specify exactly one of fn, domain or bits"
97106
if fn is None:
98107
assert args is None, "args has no effect without fn"
99108
self.domain = domain
@@ -219,7 +228,8 @@ def add_constraint(self, constr: utils.Constraint) -> None:
219228
220229
:param constr: Constraint to add.
221230
'''
222-
if self.length > 0:
231+
length = self.get_length()
232+
if length is not None:
223233
# Treat all additional constraints as list constraints,
224234
# although this is a little less performant.
225235
self.list_constraints.append(constr)
@@ -230,6 +240,35 @@ def add_constraint(self, constr: utils.Constraint) -> None:
230240
self.check_constraints = True
231241
self.randomizer = self.create_randomizer()
232242

243+
def get_length(self) -> int:
244+
'''
245+
Function to get the length of the random list.
246+
247+
:return: The length of the list.
248+
'''
249+
if self.rand_length is None:
250+
return self.length
251+
if self.rand_length_val is None:
252+
raise RuntimeError("RandVar was marked as having a random length," \
253+
" but none was given when get_length was called.")
254+
return self.rand_length_val
255+
256+
def set_rand_length(self, length: int) -> None:
257+
'''
258+
Function to set the random length.
259+
260+
Should only be used when this ``RandVar``
261+
instance is indicated to have a random length
262+
that depends on another variable.
263+
264+
:raises RuntimeError: If this variable instance is not
265+
marked as one with a random length.
266+
'''
267+
if self.rand_length is None:
268+
raise RuntimeError("RandVar was not marked as having a random length," \
269+
" but set_rand_length was called.")
270+
self.rand_length_val = length
271+
233272
def _get_random(self) -> random.Random:
234273
'''
235274
Internal function to get the appropriate randomization object.
@@ -254,14 +293,20 @@ def get_domain_size(self) -> int:
254293
# of this variable. Return 1.
255294
return 1
256295
else:
257-
# length == 0 implies a scalar variable, 1 is a list of length 1
258-
if self.length == 0 or self.length == 1:
296+
length = self.get_length()
297+
if length is None:
298+
# length is None implies a scalar variable.
299+
return len(self.domain)
300+
elif length == 0:
301+
# This is a zero-length list, adding no complexity.
302+
return 1
303+
elif length == 1:
259304
return len(self.domain)
260305
else:
261306
# In this case it is effectively cartesian product, i.e.
262307
# n ** k, where n is the size of the domain and k is the length
263308
# of the list.
264-
return len(self.domain) ** self.length
309+
return len(self.domain) ** length
265310

266311
def can_use_with_constraint(self) -> bool:
267312
'''
@@ -284,18 +329,22 @@ def get_constraint_domain(self) -> utils.Domain:
284329
:return: the variable's domain in a format that will work
285330
with the ``constraint`` package.
286331
'''
287-
if self.length == 0:
332+
length = self.get_length()
333+
if length is None:
288334
# Straightforward, scalar
289335
return self.domain
290-
elif self.length == 1:
336+
elif length == 0:
337+
# List of length zero - an empty list is only correct choice.
338+
return [[]]
339+
elif length == 1:
291340
# List of length one
292341
return [[x] for x in self.domain]
293342
else:
294343
# List of greater length, cartesian product.
295344
# Beware that this may be an extremely large domain.
296345
# Ensure each element is of type list, which is what
297346
# we want to return.
298-
return [list(x) for x in product(self.domain, repeat=self.length)]
347+
return [list(x) for x in product(self.domain, repeat=length)]
299348

300349
def randomize_once(self, constraints: Iterable[utils.Constraint], check_constraints: bool, debug: bool) -> Any:
301350
'''
@@ -381,7 +430,7 @@ def randomize_list_naive(
381430
constraints: Iterable[utils.Constraint],
382431
check_constraints: bool,
383432
list_constraints: Iterable[utils.Constraint],
384-
debug : bool,
433+
debug: bool,
385434
debug_fail: Optional[RandomizationFail],
386435
):
387436
'''
@@ -398,11 +447,13 @@ def randomize_list_naive(
398447
all debug info along the way and not just the final failure.
399448
:param debug_fail: :class:`RandomizationFail` containing debug info,
400449
if in debug mode, else ``None``.
450+
401451
:return: A random list of values for the variable, respecting
402452
the constraints.
403453
'''
454+
length = self.get_length()
404455
values = [self.randomize_once(constraints, check_constraints, debug) \
405-
for _ in range(self.length)]
456+
for _ in range(length)]
406457
values_valid = len(list_constraints) == 0
407458
iterations = 0
408459
max_iterations = self.max_iterations
@@ -421,7 +472,7 @@ def randomize_list_naive(
421472
debug_fail.add_values(iterations, {self.name: values})
422473
iterations += 1
423474
values = [self.randomize_once(constraints, check_constraints, debug) \
424-
for _ in range(self.length)]
475+
for _ in range(length)]
425476
return values
426477

427478
def randomize_list_subset(
@@ -451,13 +502,14 @@ def randomize_list_subset(
451502
:raises RandomizationError: When the problem cannot be solved in fewer than
452503
the allowed number of iterations.
453504
'''
505+
length = self.get_length()
454506
values = [self.randomize_once(constraints, check_constraints, debug) \
455-
for _ in range(self.length)]
507+
for _ in range(length)]
456508
values_valid = len(list_constraints) == 0
457509
iterations = 0
458510
# Allow more attempts at a list, as it may be computationally hard.
459511
# Assume it's linearly harder.
460-
max_iterations = self.max_iterations * self.length
512+
max_iterations = self.max_iterations * length
461513
checked = []
462514
while not values_valid:
463515
iterations += 1
@@ -473,7 +525,7 @@ def randomize_list_subset(
473525
raise utils.RandomizationError("Too many iterations, can't solve problem", debug_info)
474526
# Keep a subset of the answer, to try to ensure forward progress.
475527
min_group_size = len(checked) + 1
476-
for idx in range(min_group_size, self.length):
528+
for idx in range(min_group_size, length):
477529
tmp_values = values[:idx]
478530
problem = constraint.Problem()
479531
problem.addVariable(self.name, (tmp_values,))
@@ -493,7 +545,7 @@ def randomize_list_subset(
493545
# degrees of freedom.
494546
checked = tmp_values
495547
values = checked + [self.randomize_once(constraints, check_constraints, debug) \
496-
for _ in range(self.length - len(checked))]
548+
for _ in range(length - len(checked))]
497549
problem = constraint.Problem()
498550
problem.addVariable(self.name, (values,))
499551
for con in list_constraints:
@@ -507,7 +559,7 @@ def randomize_list_subset(
507559
def randomize(
508560
self,
509561
temp_constraints: Optional[Iterable[utils.Constraint]]=None,
510-
debug: bool=False
562+
debug: bool=False,
511563
) -> Any:
512564
'''
513565
Returns a random value based on the definition of this random variable.
@@ -525,7 +577,8 @@ def randomize(
525577
# adding any temporary ones in.
526578
constraints = list(self.constraints)
527579
using_temp_constraints = temp_constraints is not None and len(temp_constraints) > 0
528-
if self.length == 0:
580+
length = self.get_length()
581+
if length is None:
529582
# Interpret temporary constraints as scalar constraints
530583
if using_temp_constraints:
531584
check_constraints = True

constrainedrandom/randobj.py

Lines changed: 72 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -61,6 +61,7 @@ def __init__(
6161
# Prefix 'internal use' variables with '_', as randomized results are populated to the class
6262
self._random = _random
6363
self._random_vars = {}
64+
self._rand_lengths = defaultdict(list)
6465
self._constraints : List[utils.ConstraintAndVars] = []
6566
self._constrained_vars = set()
6667
self._max_iterations = max_iterations
@@ -133,7 +134,8 @@ def add_rand_var(
133134
args: Optional[tuple]=None,
134135
constraints: Optional[Iterable[utils.Constraint]]=None,
135136
list_constraints: Optional[Iterable[utils.Constraint]]=None,
136-
length: int=0,
137+
length: Optional[int]=None,
138+
rand_length: Optional[str]=None,
137139
order: int=0,
138140
initial: Any=None,
139141
disable_naive_list_solver: bool=False,
@@ -160,8 +162,13 @@ def add_rand_var(
160162
specified.
161163
:param constraints: List or tuple of constraints that apply to this random variable.
162164
Each of these apply across the values in the list, if a length is specified.
163-
:param length: Specify a length > 0 to turn this variable into a list of random
164-
values. A value of 0 means a scalar value. A value >= 1 means a list of that length.
165+
:param length: Specify a length >= 0 to turn this variable into a list of random
166+
values. A value >= 0 means a list of that length. A zero-length list is just
167+
an empty list. A value of ``None`` (default) means a scalar value.
168+
Mutually exclusive with ``rand_length``.
169+
:param rand_length: Specify the name of a random variable that defines the length
170+
of this variable. The variable must have already been added to this instance.
171+
Mutually exclusive with ``length``.
165172
:param order: The solution order for this variable with respect to other variables.
166173
:param initial: Initial value to assign to the variable prior to randomizing.
167174
:param disable_naive_list_solver: Attempt to use a faster algorithm for solving
@@ -197,8 +204,24 @@ def my_fn(factor):
197204
rand_obj.add_rand_var('fn_based_with_args', fn=my_fn, args=(2,))
198205
'''
199206
# Check this is a valid name
200-
assert name not in self.__dict__, f"random variable name {name} is not valid, already exists in object"
201-
assert name not in self._random_vars, f"random variable name {name} is not valid, already exists in random variables"
207+
assert name not in self.__dict__, f"random variable name '{name}' is not valid, already exists in object"
208+
assert name not in self._random_vars, f"random variable name '{name}' is not valid, already exists in random variables"
209+
# rand_length and length are mutually-exclusive.
210+
assert not ((length is not None) and (rand_length is not None)), \
211+
"length and rand_length are mutually-exclusive, but both were specified"
212+
if rand_length is not None:
213+
# Indicates the length of the RandVar depends on another random variable.
214+
assert rand_length in self._random_vars, f"random variable length '{name}' is not valid," \
215+
" it must be a fixed integer value or the name of an existing random variable."
216+
assert self._random_vars[rand_length].length is None, f"random length '{name}' must be a scalar random" \
217+
" variable, but is itself a random list."
218+
# Track that this variable depends on another for its length.
219+
self._rand_lengths[rand_length].append(name)
220+
# Ensure the order used for this variable is greater than
221+
# the one we depend on.
222+
# Ignore the user if they're wrong rather than raising an error.
223+
if order < self._random_vars[rand_length].order:
224+
order = self._random_vars[rand_length].order + 1
202225
self._random_vars[name] = RandVar(
203226
name=name,
204227
_random=self._random,
@@ -210,10 +233,16 @@ def my_fn(factor):
210233
constraints=constraints,
211234
list_constraints=list_constraints,
212235
length=length,
236+
rand_length=rand_length,
213237
max_iterations=self._max_iterations,
214238
max_domain_size=self._max_domain_size,
215239
disable_naive_list_solver=disable_naive_list_solver,
216240
)
241+
if rand_length is not None:
242+
# If rand_length is constrained by other vars,
243+
# so must the variable whose length it controls.
244+
if rand_length in self._constrained_vars:
245+
self._constrained_vars.add(name)
217246
self._problem_changed = True
218247
self.__dict__[name] = initial
219248

@@ -331,11 +360,26 @@ def randomize(
331360
constrained_vars = sorted(constrained_vars)
332361
# Don't allow non-determinism when iterating over a dict
333362
random_vars = sorted(self._random_vars.items())
363+
list_length_vars = sorted(self._rand_lengths.items())
334364

335365
# Process concrete values - use these preferentially
336366
with_values = with_values if with_values is not None else {}
337367

368+
# Randomize list length vars first
369+
for name, dependent_var_list in list_length_vars:
370+
if name in with_values:
371+
length_result = with_values[name]
372+
else:
373+
tmp_constraints = tmp_single_var_constraints.get(name, [])
374+
length_result = self._random_vars[name].randomize(tmp_constraints, debug)
375+
result[name] = length_result
376+
for dependent_var_name in dependent_var_list:
377+
self._random_vars[dependent_var_name].set_rand_length(length_result)
378+
379+
# Randomize all remaining variables once
338380
for name, random_var in random_vars:
381+
if name in self._rand_lengths:
382+
continue
339383
if name in with_values:
340384
result[name] = with_values[name]
341385
else:
@@ -365,13 +409,33 @@ def randomize(
365409
result.update(solution)
366410
else:
367411
# No solution found, re-randomize and try again
412+
# List length variables first
413+
for name, dependent_var_list in list_length_vars:
414+
# If the length-defining variable is constrained,
415+
# re-randomize it and all its dependent vars.
416+
if name not in with_values and name in constrained_vars:
417+
tmp_constraints = tmp_single_var_constraints.get(name, [])
418+
length_result = self._random_vars[name].randomize(tmp_constraints, debug)
419+
result[name] = length_result
420+
# Need to re-randomize all dependent vars as their
421+
# length has changed.
422+
for dependent_var_name in dependent_var_list:
423+
self._random_vars[dependent_var_name].set_rand_length(length_result)
424+
tmp_constraints = tmp_single_var_constraints.get(dependent_var_name, [])
425+
result[dependent_var_name] = self._random_vars[dependent_var_name].randomize(tmp_constraints, debug)
368426
for var in constrained_vars:
369427
# Don't re-randomize if we've specified a concrete value
370428
if var in with_values:
371429
continue
372-
else:
373-
tmp_constraints = tmp_single_var_constraints.get(var, [])
374-
result[var] = self._random_vars[var].randomize(tmp_constraints, debug)
430+
# Don't re-randomize list-length vars, those have been dealt with.
431+
if var in self._rand_lengths:
432+
continue
433+
# Don't re-randomize list vars which have been re-randomized once already.
434+
rand_length = self._random_vars[var].rand_length
435+
if rand_length is not None and rand_length in constrained_vars:
436+
continue
437+
tmp_constraints = tmp_single_var_constraints.get(var, [])
438+
result[var] = self._random_vars[var].randomize(tmp_constraints, debug)
375439
attempts += 1
376440

377441
# If constraints are still not satisfied by this point, construct a multi-variable

0 commit comments

Comments
 (0)