How to use constrainedrandom
The following is a guide on how to use all the features of constrainedrandom.
Code snippets are provided and the user is encouraged to try them out.
The RandObj class
The library provides one main user-facing class, RandObj.
This class can be instanced or inherited from to create randomization problems.
Here is an example of creating an instance of the class:
from constrainedrandom import RandObj
# Create a problem where two random numbers
# between 0 and 9 must have a sum greater than 5.
randobj = RandObj()
randobj.add_rand_var("a", domain=range(10))
randobj.add_rand_var("b", domain=range(10))
def sum_gt_5(a, b):
return a + b > 5
randobj.add_constraint(sum_gt_5, ('a', 'b'))
randobj.randomize()
print("a", randobj.a, "b", randobj.b)
Here is the same example implemented by inheriting from the class:
from constrainedrandom import RandObj
# Create a problem where two random numbers
# between 0 and 9 must have a sum greater than 5.
class SumGTFive(RandObj):
def __init__(self):
super().__init__()
self.add_rand_var("a", domain=range(10))
self.add_rand_var("b", domain=range(10))
self.add_constraint(self.sum_gt_5, ('a', 'b'))
def sum_gt_5(self, a, b):
return a + b > 5
randobj = SumGTFive()
randobj.randomize()
print("a", randobj.a, "b", randobj.b)
Repeatability
The RandObj class uses the standard Python random package under the hood. random provides repeatable randomization when given the same seed.
There are two ways to seed the random package, either globally or using an instance of the random.Random class.
Global seeding
The easiest way to ensure repeatability of a program with a given seed is to use global seeding of the random pacakage at the beginning of your program.
Here is an example of ensuring repeatability using global seeding:
import random
# Use seed 0.
random.seed(0)
for _ in range(5):
print(random.randrange(10))
# Use seed 0 again, this will produce the same 5 random values.
random.seed(0)
for _ in range(5):
print(random.randrange(10))
Here is an example how to ensure repeatability of RandObj behaviour using global seeding:
import random
from constrainedrandom import RandObj
def my_function(seed, name):
# Seed the random package globally.
random.seed(seed)
# Don't pass anything in the constructor to RandObj,
# rely on random seeding for repeatability.
randobj1 = RandObj()
randobj1.add_rand_var("a", domain=range(10))
randobj2 = RandObj()
randobj2.add_rand_var("b", bits=4)
# Create and print some random values.
values = []
for _ in range(5):
randobj1.randomize()
randobj2.randomize()
values.append(f"a={randobj1.a}, b={randobj2.b}")
print(name, values)
# Using the same seed will produce the same results.
my_function(0, "first call:")
my_function(0, "second call:")
# Using a different seed produces different results.
my_function(1, "third call:")
Object-based seeding
You can also manage repeatability by passing around an instance of random.Random.
Here is an example of ensuring repeatability using the random.Random class:
from random import Random
# Use seed 0.
rand1 = Random(0)
for _ in range(5):
print(rand1.randrange(10))
# Use seed 0 again, this will produce the same 5 random values.
rand2 = Random(0)
for _ in range(5):
print(rand2.randrange(10))
The RandObj class accepts an instance of the random.Random class in its constructor. In this usage model, use one random.Random object per seed in a program, and to pass it to all the RandObj objects used in the program, to ensure that results are repeatable.
from random import Random
from constrainedrandom import RandObj
def my_function(seed, name):
# Create an instance of random.Random with the desired seed.
rand = Random(seed)
# Create two random objects based on the same shared random generator.
randobj1 = RandObj(rand)
randobj1.add_rand_var("a", domain=range(10))
randobj2 = RandObj(rand)
randobj2.add_rand_var("b", bits=4)
# Create and print some random values.
values = []
for _ in range(5):
randobj1.randomize()
randobj2.randomize()
values.append(f"a={randobj1.a}, b={randobj2.b}")
print(name, values)
# Using the same seed will produce the same results.
my_function(0, "first call:")
my_function(0, "second call:")
# Using a different seed produces different results.
my_function(1, "third call:")
Copying and repeatability
RandObj supports Python’s copy.deepcopy. However, there is a caveat that users should be aware of.
A RandObj that is seeded with the object-based approach above is guaranteed to be repeatable from the point where it is copied.
E.g. the following code will run fine with no assertion error:
import copy
import random
from constrainedrandom import RandObj
randobj = RandObj(random.Random(0))
randobj.add_rand_var('x', domain=range(100))
randobj_copy = copy.deepcopy(randobj)
randobj.randomize()
randobj_copy.randomize()
# The results of both will be the same.
assert randobj.get_results() == randobj_copy.get_results(), "this should be true!"
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.
E.g. the following code will NOT give reproducible results between the original object and its copy.
import copy
import random
from constrainedrandom import RandObj
random.seed(0)
randobj = RandObj()
randobj.add_rand_var('x', domain=range(100))
# Take a copy
randobj_copy = copy.deepcopy(randobj)
randobj.randomize()
# re-seed global random to try to make it reproducible
random.seed(0)
randobj_copy.randomize()
# The following will fail for many RandObj instances:
assert randobj.get_results() == randobj_copy.get_results(), "this will fail!"
There is another issue where constraints added to the original may affect the copy, and vice versa. This is obviously not a good thing.
Therefore, the user is recommended not to use copy.deepcopy with globally-seeded instances of RandObj.
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.
Adding random variables
The RandObj class provides a means to create randomizable objects. The user may either inherit from RandObj, or simply create instances of it, depending on the problem they are trying to solve.
A RandObj has random variables. Each random variable will be randomized to one of its possible values when the randomize() method is called.
The method to add a random variable is add_rand_var, which accepts a number of arguments. At mimimum, the user must specify a name for the variable, and a means to determine its possible values. The possible values can be expressed either as a size in bits, a “domain”, or a custom function and its arguments.
bits
Using the bits argument to add_rand_var means that the random variable’s possible values are all the values that would fit into a bit field value of that width. For example, if the user used r.add_rand_var('a', bits=8), variable a would have possible values 0 <= value < (1 << 8).
import random
from constrainedrandom import RandObj
# Ensure repeatability.
random.seed(0)
# Create an instance of RandObj.
r = RandObj()
# Add a random variable called 'fred', which is 8 bits wide.
r.add_rand_var('fred', bits=8)
# Add another random variable called 'bob', which is 4 bits wide.
r.add_rand_var('bob', bits=4)
# Randomize all variables.
r.randomize()
# The values of the randomizable variables are available
# as attributes of the object.
print(r.fred)
print(r.bob)
domain
- A “domain” denotes the possible values of the variable. A domain is one of:
a
rangeof possible valuesa
listortupleof possible valuesa
dictspecifying a weighted distribution of possible values (which may itself contain arangeof possible values)an
EnumorIntEnumclass.
import random
from enum import Enum
from constrainedrandom import RandObj
# Ensure repeatability.
random.seed(0)
# Create an instance of RandObj.
r = RandObj()
# Add a random variable called 'foo',
# whose value is greater than or equal to 4 and less than 42.
r.add_rand_var('foo', domain=range(4, 42))
# Add a random variable called 'bar',
# whose value is one of the first 5 prime numbers.
r.add_rand_var('bar', domain=(2, 3, 5, 7, 11))
# Add a random variable called 'baz',
# whose value is a distribution weighted to choose 0 half of the time,
# 1 a quarter of the time, and 2 <= value < 10 a quarter of the time.
r.add_rand_var(
'baz',
domain={
0: 50,
1: 25,
range(2,10): 25
}
)
# Add a random variable called 'my_enum', with a domain
# corresponding to the values of an Enum class.
# Values SomeEnum.SOME (0), SomeEnum.VALUES (1) and
# SomeEnum.HERE (10) will be chosen at random with equal weights.
class SomeEnum(Enum):
SOME = 0
VALUES = 1
HERE = 10
r.add_rand_var('my_enum', domain=SomeEnum)
r.randomize()
fn
A custom function can be provided to dertermine the value of a random variable. Such a custom function is provided via the fn argument.
It is optional whether the function requires arguments. If it does, these can also be specified as a tuple and passed via the args argument.
The custom function will be called to determine the value of the random variable. If arguments are provided via the args argument, these will be passed to the function when it is called.
import random
import time
from constrainedrandom import RandObj
# Ensure repeatability.
random.seed(0)
# Create an instance of RandObj.
r = RandObj()
# Add a random variable called 'time',
# whose value is determined by calling the function time.time
r.add_rand_var('time', fn=time.time)
# Add a random variable called 'time_plus_one',
# whose value is determined by calling our custom function 'my_func'
# with argument 1.
def my_func(time_delta):
return time.time() + time_delta
r.add_rand_var('time_plus_one', fn=my_func, args=(1,))
# Note that calling randomize here just calls these functions to
# populate the variables.
r.randomize()
print(r.time, r.time_plus_one)
time.sleep(1)
# This will call the functions again, yielding different results.
r.randomize()
print(r.time, r.time_plus_one)
Sometimes, we want the custom function to perform procedural randomization, if it’s easier to express what we want that way.
Note
In order to maintain repeatability, if using the Object-based seeding approach, the function should not perform randomization independent from the same instance of Random used to control the RandObj’s seeding.
The following is OK because we are using global seeding:
import random
from constrainedrandom import RandObj
random.seed(0)
r = RandObj()
# Add a random variable called 'multiple_of_4',
# whose value is determined by calling the function rand_mul_by_4.
def rand_mul_by_4():
val = random.randrange(1,10)
return val * 4
r.add_rand_var('multiple_of_4', fn=rand_mul_by_4)
r.randomize()
The following is OK because the function uses the same random seeding object as the RandObj instance that it is added to:
from random import Random
from constrainedrandom import RandObj
rand = Random(0)
r = RandObj(rand)
# Add a random variable called 'multiple_of_4',
# whose value is determined by calling the function rand_mul_by_4.
def rand_mul_by_4():
val = rand.randrange(1,10)
return val * 4
r.add_rand_var('multiple_of_4', fn=rand_mul_by_4)
r.randomize()
However, the following is not OK because the rand_mul_by_4 function is not seeded by the same object as the RandObj instance. This will give unrepeatable results because rand_mul_by_4 is using the base Python random package instead:
import random
from constrainedrandom import RandObj
rand = random.Random(0)
r = RandObj(rand)
# Add a random variable called 'multiple_of_4',
# whose value is determined by calling the function rand_mul_by_4.
def rand_mul_by_4():
val = random.randrange(1,10)
return val * 4
r.add_rand_var('multiple_of_4', fn=rand_mul_by_4)
r.randomize()
Random list variables
Sometimes, we might want to produce a list of randomized values. constrainedrandom supports this. You can turn a random variable into a list by supplying the length argument. length=None is the default behaviour, i.e. a scalar value. length=1 means a list of one randomized value, similarly length=N means a list of N randomized values. length=0 is a list of length zero, i.e. the empty list, []. This is permitted for completeness’ sake.
Here is an example of a randomized list variable.
import random
from constrainedrandom import RandObj
rand = random.Random(0)
r = RandObj(rand)
# Add a variable which is a list of 10 random values between 0 and 99
r.add_rand_var('listvar', domain=range(100), length=10)
r.randomize()
See the section below on List Constraints to see how adding constraints to this kind of variable works.
Random list with random length
If the user desires the length of a random list to be randomized, this can also be achieved with constrainedrandom.
Firstly, add a random variable that will be used to determine the length of the random list.
Secondly, add the random list variable, using the rand_length argument. Specify the name of the variable that determines the random length which was previously added.
E.g. this code adds a variable and then uses it as a random length:
import random
from constrainedrandom import RandObj
random.seed(0)
r = RandObj()
# Add varaible to use as random length, between 0 and 9
r.add_rand_var('rand_length', domain=range(10))
# Use this variable as the rand_length argument to a list,
# meaning the list will have length equal to rand_length
# upon randomization.
r.add_rand_var('rand_list', bits=8, rand_length='rand_length')
r.randomize()
print(r.get_results())
# Prints:
# {'rand_length': 6, 'rand_list': [194, 227, 107, 10, 66, 247]}
Note
length and rand_length are mutually-exclusive arguments - the length is either fixed or random.
Warning
Be careful of using a variable for rand_length which might give an illegal list length. List lengths are illegal if they are not an instance of int, or if they are less than zero. E.g. using a variable for rand_length whose domain includes negative numbers might result in errors.
Note
Using rand_length and order together may mean that order is ignored. The library will ensure that a variable used as a random length will always be randomized prior to the list that relies on it. If the user tries to specify an order that defies this principle, it will be ignored with no error or warning message.
Initial values
An initial value for a variable can be provided. This value is assigned to the variable before randomization takes place. By default, an un-randomized variable in a RandObj instance has the initial value None.
import random
from constrainedrandom import RandObj
random.seed(0)
r = RandObj()
# No initial value supplied.
r.add_rand_var('a', bits=10)
# This will print `None`.
print(r.a)
# Initial value supplied.
r.add_rand_var('b', domain=range(100), initial='not defined')
# This will print `not defined`.
print(r.b)
# This will give the variables random values.
# After this, the initial value is no longer captured.
r.randomize()
print("a", r.a, "b", r.b)
Constraints
Constraints restrict the possible values of one or more of the random variables.
There are two types of constraints:
- Single-variable constraints
affect one variable only.
- Multi-variable constraints
affect more than one variable, and must be added to the
RandObjinstance after the associated variables are added.
All constraints are expressed as a function taking the relevant random variables as arguments, and returing True if the constraint is satisfied and False otherwise.
E.g. let’s say we want to add a constraint that the value of a variable must not be zero. To define a function expressing that constraint, we would write the following:
def not_zero(x):
return x != 0
This function returns True when called with a value that is not zero, and False if the value is equal to zero, i.e. the constraint is satisfied for a value that is non-zero, but not satisfied when the value is zero.
>>> not_zero(1)
True
>>> not_zero(0)
False
The add_constraint function is used to add constraints to a RandObj. The variables that a constraint affects must have already been added to the problem before add_constraint is called.
In general, constraints should be used as sparingly as possible, as they are the main source of complexity when it comes to solving these problems.
Single-variable constraints
Single-variable constraints constrain one variable only. They are not as much of a burden as multi-variable constraints because they don’t introduce dependencies between variables.
They can be added when adding the variable with add_rand_var, or afterwards with add_constraint. For best performance, they should be added to a variable when add_rand_var is called.
import random
from constrainedrandom import RandObj
random.seed(0)
r = RandObj()
# Add a random variable called 'plus_minus', which follows these rules:
# -10 < plus_minus < 10
# plus_minus != 0
# We define a function to act as a constraint, it returns True if the
# constraint is satisfied and False if it isn't:
def not_zero(x):
return x != 0
r.add_rand_var('plus_minus', domain=range(-9, 10), constraints=(not_zero,))
r.randomize()
Equally we could have used a lambda above for brevity:
r.add_rand_var('plus_minus', domain=range(-9, 10), constraints=(lambda x : x != 0,))
Note that the above example would be more efficiently implemented by replacing the constraint by providing a more precise domain:
r.add_rand_var('plus_minus', domain=list(range(-9, -1)) + list(range(1,10)))
Sometimes, however, it is just much easier to express what you want with a constraint:
def not_reserved_values(x):
return x not in (0xdeadbeef, 0xcafef00d)
r.add_rand_var(
'address',
domain={
range(0x00000000, 0x10000000) : 10,
range(0x10000000, 0xd0000000) : 1,
range(0xd0000000, 0xffffffff) : 5,
},
constraints=(not_reserved_values,)
)
The above simply restricts the values of address never to be 0xdeadbeef or 0xcafef00d, without otherwise affecting the distribution of values.
While it is recommended to add constraints to individual variables as they are added with add_rand_var, it is also possible to add constraints to single variables with add_constraint. This is especially useful when using inheritance patterns.
import random
from constrainedrandom import RandObj
random.seed(0)
# Create a problem that gives a random number
# between 0 and 99 which is not divisible by 3.
class NotDivisibleByThree(RandObj):
def __init__(self):
super().__init__()
def not_div_by_three(x):
return x % 3 != 0
self.add_rand_var("a", domain=range(100), constraints=[not_div_by_three])
randobj = NotDivisibleByThree()
randobj.randomize()
print("a", randobj.a)
# Create a problem with the same constraints as above,
# but also where the number must not be a multiple of 5.
class DivisibleByFive(NotDivisibleByThree):
def __init__(self):
super().__init__()
def div_by_five(x):
return x % 5 == 0
self.add_constraint(div_by_five, ('a',))
randobj = DivisibleByFive()
randobj.randomize()
print("a", randobj.a)
Multi-variable constraints
Usually the most useful part of declarative-style constrained random testing is adding constraints that affect the value of multiple variables. Unfortunately, it is also the biggest single source of complexity when solving problems. The add_constraint function can be called on a RandObj to add another constraint to a problem.
In constrainedrandom, multi-variable constraints are added to a RandObj instance after from the variables they affect have already been added.
import random
from constrainedrandom import RandObj
random.seed(0)
r = RandObj()
# Add two random variables whose sum should not overflow 16 bits.
r.add_rand_var('op0', bits=16)
r.add_rand_var('op1', bits=16)
# Define a function to describe the constraint that the sum
# should not overflow 16 bits.
def not_overflow_16(x, y):
return (x + y) < (1 << 16)
r.add_constraint(not_overflow_16, ('op0', 'op1'))
r.randomize()
List Constraints
When creating a random list variable, we can specify two types of constraints - regular (or scalar) constraints which affect the scalar value in each element of the list, and list constraints wich affect the values of the list relative to each other. The list_constraints argument is used to supply constraints on the whole list.
Here is an example of using mixed scalar and list constraints for a list variable. We also make use of the provided unique method which ensures there are no repeated elements.
import random
from constrainedrandom import RandObj
from constrainedrandom.utils import unique
rand = random.Random(0)
r = RandObj(rand)
# Add a variable which is a list of 10 random values between 0 and 99.
# Each value should be a multiple of 2 or 3.
# The list shouldn't have any repeated elements.
# The total sum of the elements shouldn't be below 50.
def multiple_of_2_or_3(val):
return (val % 2 == 0) or (val % 3 == 0)
def sum_gt_50(list_of_vals):
total = 0
for val in list_of_vals:
total += val
return total >= 50
r.add_rand_var(
'listvar',
domain=range(100),
length=10,
constraints=(multiple_of_2_or_3,),
list_constraints=(sum_gt_50, unique,)
)
r.randomize()
If any multi-variable constraints are added that affect a list variable, its type should be treated as a list. E.g.:
import random
from constrainedrandom import RandObj
from constrainedrandom.utils import unique
rand = random.Random(0)
r = RandObj(rand)
# Add a variable which is a list of 10 random values between 0 and 99.
r.add_rand_var('listvar', domain=range(100), length=10)
# Add another variable
r.add_rand_var('a', domain=range(10))
# Add a multi-variable constraint, treating listvar's type as a list.
# Ensure 'a' does not appear in the list.
def not_in_list(a, listvar):
return not (a in listvar)
r.add_constraint(not_in_list, ('a', 'listvar'))
r.randomize()
Temporary constraints
Sometimes, we want to apply an extra constraint for one randomization attempt, without permanently modifying the underlying problem. In this case, we can apply one or more temporary constraints.
To achieve this, we use the with_constraints argument to randomize:
import random
from constrainedrandom import RandObj
random.seed(0)
r = RandObj()
# Add two random variables whose sum should not overflow 16 bits.
r.add_rand_var('op0', bits=16)
r.add_rand_var('op1', bits=16)
# Define a function to describe the constraint that the sum
# should not overflow 16 bits.
def not_overflow_16(x, y):
return (x + y) < (1 << 16)
r.add_constraint(not_overflow_16, ('op0', 'op1'))
r.randomize()
print(r.op0, r.op1)
# On this occasion, we need op0 to be an even number.
# Add a temporary constraint.
def is_even(x):
return x % 2 == 0
r.randomize(with_constraints=[(is_even, ('op0',))])
print(r.op0, r.op1)
# Go back to a randomization just with the original constraints.
r.randomize()
print(r.op0, r.op1)
Temporary constraints can also apply to multiple variables:
# On this occasion, we need op0 + op1 to be an even number.
# Add a temporary constraint.
def is_even(x):
return x % 2 == 0
def sum_even(x, y):
return is_even(x + y)
r.randomize(with_constraints=[(sum_even, ('op0', 'op1'))])
print(r.op0, r.op1)
Note
Temporary constraints make solving the constraint problem even harder than regular constraints, so please try to use them sparingly for best peformance.
Temporary values
Sometimes, for one randomization attempt, we want to specify a concrete value for a particular variable. In this case, we can randomize with values specified for one or more variable in the problem. This skips randomization for that variable and assigns it the value given. Other variables must still satisfy their constraints with respect to any variables with concrete values.
To achieve this, we use the with_values argument to randomize:
import random
from constrainedrandom import RandObj
random.seed(0)
r = RandObj()
# Add two random variables whose sum should not overflow 16 bits.
r.add_rand_var('op0', bits=16)
r.add_rand_var('op1', bits=16)
# Define a function to describe the constraint that the sum
# should not overflow 16 bits.
def not_overflow_16(x, y):
return (x + y) < (1 << 16)
r.add_constraint(not_overflow_16, ('op0', 'op1'))
r.randomize()
print(r.op0, r.op1)
# On this occasion, we want op0 to be equal to 42.
# The randomization will still ensure that 42 + op1 does not
# overflow 16 bits, as per the above constraint.
r.randomize(with_values={'op0': 42})
print(r.op0, r.op1)
# Randomization just with the original constraints.
r.randomize()
print(r.op0, r.op1)
We can mix temporary values with temporary constraints:
# On this occasion, we want op0 to be equal to 42
# and op1 to be even.
# Again the original overflow constraint will still
# be satisfied.
def is_even(val):
return val % 2 == 0
r.randomize(with_constraints=[(is_even, ('op1',))], with_values={'op0': 42})
print(r.op0, r.op1)
Class-based constraints
If an object inherits from RandObj, it may use class methods as constraints. Class methods will not be used as constraints by default unless added with add_constraint. They must be added as constraints in the normal way, e.g. self.add_constraint(self.methodname, ('random_variable1', 'random_variable2')).
Class methods used as constraints may refer to non-random member variables of the class via the self argument.
from constrainedrandom import RandObj
class MyRandObj(RandObj):
def __init__(self, min_val, max_val, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_rand_var('value', domain=range(10))
self.min_val = min_val
self.max_val = max_val
# We need to register the class method as a constraint,
# and we still need to pass it the list of random variables
# it operates on.
self.add_constraint(self.value_c, ('value',))
def value_c(self, value):
# This function still must take arguments corresponding
# to the random variables it checks.
return self.min_val <= value <= self.max_val
def set_min_max(self, new_min, new_max):
# This is not used as a constraint, because it is not added
# using `add_constraint`
self.min_val = new_min
self.max_val = new_max
myrandobj = MyRandObj(2, 8)
myrandobj.randomize()
print(myrandobj.value)
myrandobj.set_min_max(3, 7)
myrandobj.randomize()
print(myrandobj.value)
Class methods used as constraints must still accept randomizable variables as arguments. Users should not refer to random variables using the self argument inside a constraint, because this refers to the previous value, not the one being randomized and checked when randomize() is called.
For example, the below is not correct and will not behave as desired:
# This is wrong - don't do this!
class BadRandObj(RandObj):
def __init__(self, min_val, max_val, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_rand_var('value', domain=range(10))
self.min_val = min_val
self.max_val = max_val
self.add_constraint(self.value_c, [])
def value_c(self):
# `self.value` here refers to the old value,
# so this doesn't do what we want:
return self.min_val <= self.value <= self.max_val
Think of the values being passed to the constraint as the new values being checked, and anything under self as the previous set of values stored in the class before randomize() is called.
constrainedrandom currently has the limitation that the user has to distinguish between random and non-random variables in this way. This can be addressed in the future for added user-friendliness.
Class-based constraints and inheritance
We can use normal Python inheritance to override constraints in child classes. Using the correct MyRandObj from above, we can create a child class, and override value_c to check the value is even:
class ChildClass(MyRandObj):
def value_c(self, value):
return self.min_val <= value <= self.max_val and value % 2 == 0
child = ChildClass(2, 8)
child.randomize()
print(child.value)
This works because the parent constructor will use the overridden value_c when adding as a constraint it in its constructor. It has to have the same arguments as the parent value_c in order to work correctly.
Pure vs non-pure constraints
A pure function is one whose result only depends on its inputs and has no side effects.
In constrainedrandom, we define a pure constraint as one whose result only depends on its inputs, which are random variables that are defined as part of the randomization problem.
E.g. the following is a pure constraint:
def not_zero(x):
return x != 0
A non-pure constraint depends on something other than its random variable inputs to produce a result.
E.g. the following is a non-pure constraint because it refers to an external (in this case, global) variable:
banned_values = [1, 3, 7, 8]
def not_banned(x):
return not (x in banned_values)
Both pure and non-pure constraints are supported by constrainedrandom. Pure constraints are optimized better by constrainedrandom than non-pure constraints, becuase we can take advantage of the fact that only variables under control by the randomization problem affect the result of the constraint. The user is encouraged to stick to pure constraints where possible for best performance.
Class-based constraints are always assumed to be non-pure, as they always accept a reference to the class instance as the first argument, which is not a random variable itself. So using these rather than pure constraints added procedurally may harm performance slightly. Although it may well be worth the cost in order to organize the code in a more object-oriented manner.
pre_randomize and post-randomize
Additional methods are provided to the user as part of RandObj: pre_randomize and post_randomize. These methods mimic the SystemVerilog ones in that they run at the very beginning and at the very end of randomize().
import random
from constrainedrandom import RandObj
class MyRandObj(RandObj):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self.add_rand_var('a', domain=range(10))
def pre_randomize(self):
print("hello, value of 'a' is", self.a)
def post_randomize(self):
print("randomization is done, value of 'a' is", self.a)
self.a = self.a + 1
print("goodbye, value of 'a' is", self.a)
random.seed(0)
r = MyRandObj()
r.randomize()
print(r.a)
Output:
hello, value of 'a' is None
randomization is done, value of 'a' is 6
goodbye, value of 'a' is 7
7
The methods can be overridden to do anything the user pleases. In the RandObj class definition they are left empty.
Optimization
This section deals with how to optimize constrainedrandom for a given problem.
Ordering hints
Sometimes, a randomization problem is much easier to solve if the variables are considered in a specific order.
Consider this example:
import random
from constrainedrandom import RandObj
random.seed(0)
r = RandObj()
r.add_rand_var("x", range(100))
r.add_rand_var("y", range(100))
def plus_one(x, y):
return y == x + 1
r.add_constraint(plus_one, ("x", "y"))
r.randomize()
In the above example, y must equal x + 1. If we randomize the two variables at the same time and check the constraints, this is hard to get right randomly (a probability of 0.001).
If we randomize the variables in an order, e.g. x first then y second, the problem becomes trivially easy. We can hint to the library what order might be most performant using ordering hints.
The order argument to add_rand_var allows us to specify what order to attempt to solve the variables with respect to one another. It defaults to order=0 if not specified.
# Solve x first, then y
r.add_rand_var("x", range(100), order=0)
r.add_rand_var("y", range(100), order=1)
Many problems will be faster to solve if the user specifies a sensible ordering hint. (Obviously, the above problem is a bit silly, and in practice the user should only randomize one variable and then add one to it.)
Warning
It is possible to significantly slow down the solver speed with bad ordering hints, so only use them when you are sure the order you’ve specified is faster.
Constraint solving algorithms
constrainedrandom employs three approaches to solving constraints, in order:
Naive solver
The default, low-effort way to solve the problem is to randomize the variables and check the concrete values against the constraints. This is called the naive solver. After a certain number of failed attempts, the solver gives up the low-effort/naive approach.
Sparse solver
A graph-based exploration of the state space. A constraint problem is constructed with variables in a given order. A random subset of possible solutions is kept at each depth of the graph in order to reduce the state space. The algorithm begins with a depth-first search, and then goes wider. It gives up after a maximum number of attempts at a maximum width.
Thorough solver
Construct a full constraint solution problem. Fully solve the CSP, finding all possible valid solutions, randomly choosing one solution. Slow, but likely to converge. There is still a restriction on the maximum domain size of the problem in order to avoid state space explosion and the program hanging. So this method might still fail.
Setting which solvers to use
Some problems may be better suited to certain solvers. The set_solver_mode function allows the user to turn each solver on or off. The order that the solvers run in as described above cannot be changed.
The example given above in Ordering hints is very hard to solve by just randomizing and checking. We can force the solver to skip the step where it randomizes and checks the constraints by disabling naive constraint solution:
r.set_solver_mode(naive=False)
This means we will skip the naive solver and move on immediately to the sparse solver. For some problems, this will speed them up, for others it will slow them down. It is best to experiment to determine whether to do this or not.
The user can turn the other solvers on/off in the same manner. If a solver is not specified in the arguments, its state will not be changed. E.g. the below will turn the sparse solver off, the thorough solver on, and not affect the state of the naive solver:
r.set_solver_mode(sparse=False, thorough=True)
Tweaking parameters
constrainedrandom has two main parameters that affect constraint solution, which can be set when constructing a RandObj.
max_iterations determines how many iterations are completed before a solver gives up. It is interpreted slightly differently between the different solvers, but broadly speaking does the same thing in each.
The higher the value of max_iterations, the more likely it is for a solver to converge. However, if a solver has no chance to converge, this makes it take longer overall for the problem to be solved, because the solver will persist for longer before giving up.
max_domain_size determines the maximum state space size that will be used with the constraint library. Because constraint is a full CSP solver, it gives all possible solutions to a CSP. The state space of a CSP can easily explode. E.g. two 32 bit variables have a state space of 2^64 when considered together. The max_domain_size sets an upper limit on this, so as not to overload constraint.
Tweaking max_domain_size is harder than max_iterations. A higher value may save time if a full constraint problem is faster than randomizing and checking, and is likely to increase the probability of convergence. However, a lower value may be much faster, to avoid computing all possibilites for easier variables.
The only real way to tell is to experiment with specific problems. To alter these properties for a given RandObj instance, use the arguments in the constructor, e.g.:
randobj = RandObj(max_iterations=1000, max_domain_size=10000)
Or when inheriting from RandObj:
class MyRandObj(RandObj):
def __init__(self):
super().__init__(max_iterations=1000, max_domain_size=10000)
Bit slices
Bit slicing is commonly required in verification activities, and is supported at a language level by HDLs like SystemVerilog. All bit slice operations can be achieved in Python by using masks, shifts and bitwise operations without any need to build it into the syntax of the language.
For user convenience, constrainedrandom provides common bitwise operations in the constrainedrandom.bits package.
get_bitslice returns the required bitslice of the input value. E.g. the following in Python:
foo = get_bitslice(val, hi, lo)
is equivalent to the following in SystemVerilog:
foo = val[hi:lo];
set_bitslice sets the bitslice of the input value with a new value and returns it. E.g. the following in Python:
val = set_bitslice(val, hi, lo, new_val)
is equivalent to the following in SystemVerilog:
val[hi: lo] = new_val;
Note that set_bitslice returns the result rather than directly modifying the input val.
Debugging
If the randomization problem described by a RandObj is unsolvable (at least within a certain effort limit), calling randomize() will fail. It will throw a RandomizationError exception, containing an instance of the RandomizationDebugInfo class. The latter will contain debug info on the most recent set of variables that failed to randomize and meet the constraints.
If debug=True is passed to randomize(), this will slow execution down a lot, but will result in the RandomizationDebugInfo instance containing all the debug information for every failed randomization attempt during the call to randomize(). This is a lot of information but might be helpful in spotting repeated patterns of constraints that cannot be solved.