Skip to content

Commit 9a6b6bd

Browse files
will-keenimgtec-admin
authored andcommitted
Merge pull request #4 from imaginationtech/splitfiles
Split constrainedrandom/__init__.py
2 parents 8a4a755 + 496f224 commit 9a6b6bd

File tree

8 files changed

+762
-666
lines changed

8 files changed

+762
-666
lines changed

constrainedrandom/__init__.py

Lines changed: 3 additions & 664 deletions
Large diffs are not rendered by default.

constrainedrandom/internal/__init__.py

Whitespace-only changes.
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2023 Imagination Technologies Ltd. All Rights Reserved
3+
4+
import constraint
5+
from collections import defaultdict
6+
from typing import Any, Dict, Iterable, List, Optional, TYPE_CHECKING, Union
7+
8+
from constrainedrandom import utils
9+
10+
if TYPE_CHECKING:
11+
from constrainedrandom.randobj import RandObj
12+
from constrainedrandom.internal.randvar import RandVar
13+
14+
15+
class MultiVarProblem:
16+
'''
17+
Multi-variable problem. Used internally by RandObj.
18+
Represents one problem concerning multiple random variables,
19+
where those variables all share dependencies on one another.
20+
21+
:param parent: The :class:`RandObj` instance that owns this instance.
22+
:param vars: The dictionary of names and :class:`RandVar` instances this problem consists of.
23+
:param constraints: The list or tuple of constraints associated with
24+
the random variables.
25+
:param max_iterations: The maximum number of failed attempts to solve the randomization
26+
problem before giving up.
27+
:param max_domain_size: The maximum size of domain that a constraint satisfaction problem
28+
may take. This is used to avoid poor performance. When a problem exceeds this domain
29+
size, we don't use the ``constraint`` package, but just use ``random`` instead.
30+
For :class:`MultiVarProblem`, we also use this to determine the maximum size of a
31+
solution group.
32+
'''
33+
34+
def __init__(
35+
self,
36+
parent: 'RandObj',
37+
vars: Dict[str, 'RandVar'],
38+
constraints: Iterable[utils.Constraint],
39+
max_iterations: int,
40+
max_domain_size: int,
41+
) -> None:
42+
self.parent = parent
43+
self.random = self.parent._random
44+
self.vars = vars
45+
self.constraints = constraints
46+
self.max_iterations = max_iterations
47+
self.max_domain_size = max_domain_size
48+
49+
def determine_order(self) -> List[List['RandVar']]:
50+
'''
51+
Chooses an order in which to resolve the values of the variables.
52+
Used internally.
53+
54+
:return: A list of lists denoting the order in which to solve the problem.
55+
Each inner list is a group of variables that can be solved at the same
56+
time. Each inner list will be considered separately.
57+
'''
58+
# Aim to build a list of lists, each inner list denoting a group of variables
59+
# to solve at the same time.
60+
# The best case is to simply solve them all at once, if possible, however it is
61+
# likely that the domain will be too large.
62+
63+
# Use order hints first, remaining variables can be placed anywhere the domain
64+
# isn't too large.
65+
sorted_vars = sorted(self.vars.values(), key=lambda x: x.order)
66+
67+
# Currently this is just a flat list. Group into as large groups as possible.
68+
result = [[sorted_vars[0]]]
69+
index = 0
70+
domain_size = len(sorted_vars[0].domain) if sorted_vars[0].domain is not None else 1
71+
for var in sorted_vars[1:]:
72+
if var.domain is not None:
73+
domain_size = domain_size * len(var.domain)
74+
if var.order == result[index][0].order and domain_size < self.max_domain_size:
75+
# Put it in the same group as the previous one, carry on
76+
result[index].append(var)
77+
else:
78+
# Make a new group
79+
index += 1
80+
domain_size = len(var.domain) if var.domain is not None else 1
81+
result.append([var])
82+
83+
return result
84+
85+
def solve_groups(
86+
self,
87+
groups: List[List['RandVar']],
88+
max_iterations:int,
89+
solutions_per_group: Optional[int]=None,
90+
) -> Union[Dict[str, Any], None]:
91+
'''
92+
Constraint solving algorithm (internally used by :class:`MultiVarProblem`).
93+
94+
:param groups: The list of lists denoting the order in which to resolve the random variables.
95+
See :func:`determine_order`.
96+
:param max_iterations: The maximum number of failed attempts to solve the randomization
97+
problem before giving up.
98+
:param solutions_per_group: If ``solutions_per_group`` is not ``None``,
99+
solve each constraint group problem 'sparsely',
100+
i.e. maintain only a subset of potential solutions between groups.
101+
Fast but prone to failure.
102+
103+
``solutions_per_group = 1`` is effectively a depth-first search through the state space
104+
and comes with greater benefits of considering each multi-variable constraint at
105+
most once.
106+
107+
If ``solutions_per_group`` is ``None``, Solve constraint problem 'thoroughly',
108+
i.e. keep all possible results between iterations.
109+
Slow, but will usually converge.
110+
:returns: A valid solution to the problem, in the form of a dictionary with the
111+
names of the random variables as keys and the valid solution as the values.
112+
Returns ``None`` if no solution is found within the allotted ``max_iterations``.
113+
'''
114+
constraints = self.constraints
115+
sparse_solver = solutions_per_group is not None
116+
117+
if sparse_solver:
118+
solved_vars = defaultdict(set)
119+
else:
120+
solved_vars = []
121+
problem = constraint.Problem()
122+
123+
for idx, group in enumerate(groups):
124+
# Construct a constraint problem where possible. A variable must have a domain
125+
# in order to be part of the problem. If it doesn't have one, it must just be
126+
# randomized.
127+
if sparse_solver:
128+
# Construct one problem per iteration, add solved variables from previous groups
129+
problem = constraint.Problem()
130+
for name, values in solved_vars.items():
131+
problem.addVariable(name, list(values))
132+
group_vars = []
133+
rand_vars = []
134+
for var in group:
135+
group_vars.append(var.name)
136+
if var.domain is not None and not isinstance(var.domain, dict):
137+
problem.addVariable(var.name, var.domain)
138+
# If variable has its own constraints, these must be added to the problem,
139+
# regardless of whether var.check_constraints is true, as the var's value will
140+
# depend on the value of the other constrained variables in the problem.
141+
for con in var.constraints:
142+
problem.addConstraint(con, (var.name,))
143+
else:
144+
rand_vars.append(var)
145+
# Add all pertinent constraints
146+
skipped_constraints = []
147+
for (con, vars) in constraints:
148+
skip = False
149+
for var in vars:
150+
if var not in group_vars and var not in solved_vars:
151+
# Skip this constraint
152+
skip = True
153+
break
154+
if skip:
155+
skipped_constraints.append((con, vars))
156+
continue
157+
problem.addConstraint(con, vars)
158+
# Problem is ready to solve, apart from any new random variables
159+
solutions = []
160+
attempts = 0
161+
while True:
162+
if attempts >= max_iterations:
163+
# We have failed, give up
164+
return None
165+
for var in rand_vars:
166+
# Add random variables in with a concrete value
167+
if solutions_per_group > 1:
168+
var_domain = set()
169+
for _ in range(solutions_per_group):
170+
var_domain.add(var.randomize())
171+
problem.addVariable(var.name, list(var_domain))
172+
else:
173+
problem.addVariable(var.name, (var.randomize(),))
174+
solutions = problem.getSolutions()
175+
if len(solutions) > 0:
176+
break
177+
else:
178+
attempts += 1
179+
for var in rand_vars:
180+
# Remove from problem, they will be re-added with different concrete values
181+
del problem._variables[var.name]
182+
# This group is solved, move on to the next group.
183+
if sparse_solver:
184+
if idx != len(groups) - 1:
185+
# Store a small number of concrete solutions to avoid bloating the state space.
186+
if solutions_per_group < len(solutions):
187+
solution_subset = self.random.choices(solutions, k=solutions_per_group)
188+
else:
189+
solution_subset = solutions
190+
solved_vars = defaultdict(set)
191+
for soln in solution_subset:
192+
for name, value in soln.items():
193+
solved_vars[name].add(value)
194+
if solutions_per_group == 1:
195+
# This means we have exactly one solution for the variables considered so far,
196+
# meaning we don't need to re-apply solved constraints for future groups.
197+
constraints = skipped_constraints
198+
else:
199+
solved_vars += group_vars
200+
201+
return self.random.choice(solutions)
202+
203+
def solve(self) -> Union[Dict[str, Any], None]:
204+
'''
205+
Attempt to solve the variables with respect to the constraints.
206+
207+
:return: One valid solution for the randomization problem, represented as
208+
a dictionary with keys referring to the named variables.
209+
:raises RuntimeError: When the problem cannot be solved in fewer than
210+
the allowed number of iterations.
211+
'''
212+
groups = self.determine_order()
213+
214+
solution = None
215+
216+
# Try to solve sparsely first
217+
sparsities = [1, 10, 100, 1000]
218+
# The worst-case value of the number of iterations for one sparsity level is:
219+
# iterations_per_sparsity * iterations_per_attempt
220+
# because of the call to solve_groups hitting iterations_per_attempt.
221+
# Failing individual solution attempts speeds up some problems greatly,
222+
# this can be thought of as pruning explorations of the state tree.
223+
# So, reduce iterations_per_attempt by an order of magnitude.
224+
iterations_per_sparsity = self.max_iterations
225+
iterations_per_attempt = self.max_iterations // 10
226+
for sparsity in sparsities:
227+
for _ in range(iterations_per_sparsity):
228+
solution = self.solve_groups(groups, iterations_per_attempt, sparsity)
229+
if solution is not None and len(solution) > 0:
230+
return solution
231+
232+
# Try 'thorough' method - no backup plan if this fails
233+
solution = self.solve_groups(groups, self.max_iterations)
234+
if solution is None:
235+
raise RuntimeError("Could not solve problem.")
236+
return solution
237+

constrainedrandom/internal/randvar.py

Lines changed: 130 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,130 @@
1+
# SPDX-License-Identifier: MIT
2+
# Copyright (c) 2023 Imagination Technologies Ltd. All Rights Reserved
3+
4+
import constraint
5+
from functools import partial
6+
from typing import Any, Callable, Iterable, Optional, TYPE_CHECKING
7+
8+
from constrainedrandom import utils
9+
10+
if TYPE_CHECKING:
11+
from constrainedrandom.randobj import RandObj
12+
13+
14+
class RandVar:
15+
'''
16+
Randomizable variable. For internal use with RandObj.
17+
18+
:param parent: The :class:`RandObj` instance that owns this instance.
19+
:param name: The name of this random variable.
20+
:param order: The solution order for this variable with respect to other variables.
21+
:param domain: The possible values for this random variable, expressed either
22+
as a ``range``, or as an iterable (e.g. ``list``, ``tuple``) of possible values.
23+
Mutually exclusive with ``bits`` and ``fn``.
24+
:param bits: Specifies the possible values of this variable in terms of a width
25+
in bits. E.g. ``bits=32`` signifies this variable can be ``0 <= x < 1 << 32``.
26+
Mutually exclusive with ``domain`` and ``fn``.
27+
:param fn: Specifies a function to call that will provide the value of this random
28+
variable.
29+
Mutually exclusive with ``domain`` and ``bits``.
30+
:param args: Arguments to pass to the function specified in ``fn``.
31+
If ``fn`` is not used, ``args`` must not be used.
32+
:param constraints: List or tuple of constraints that apply to this random variable.
33+
:param max_iterations: The maximum number of failed attempts to solve the randomization
34+
problem before giving up.
35+
:param max_domain_size: The maximum size of domain that a constraint satisfaction problem
36+
may take. This is used to avoid poor performance. When a problem exceeds this domain
37+
size, we don't use the ``constraint`` package, but just use ``random`` instead.
38+
'''
39+
40+
def __init__(self,
41+
parent: 'RandObj',
42+
name: str,
43+
order: int,
44+
*,
45+
domain: Optional[utils.Domain]=None,
46+
bits: Optional[int]=None,
47+
fn: Optional[Callable]=None,
48+
args: Optional[tuple]=None,
49+
constraints: Optional[Iterable[utils.Constraint]]=None,
50+
max_iterations: int,
51+
max_domain_size:int,
52+
) -> None:
53+
self.parent = parent
54+
self.random = self.parent._random
55+
self.name = name
56+
self.order = order
57+
self.max_iterations = max_iterations
58+
self.max_domain_size = max_domain_size
59+
assert ((domain is not None) != (fn is not None)) != (bits is not None), "Must specify exactly one of fn, domain or bits"
60+
if fn is None:
61+
assert args is None, "args has no effect without fn"
62+
self.domain = domain
63+
self.bits = bits
64+
self.fn = fn
65+
self.args = args
66+
self.constraints = constraints if constraints is not None else []
67+
if not (isinstance(self.constraints, tuple) or isinstance(self.constraints, list)):
68+
self.constraints = (self.constraints,)
69+
# Default strategy is to randomize and check the constraints.
70+
self.check_constraints = len(self.constraints) > 0
71+
# Create a function, self.randomizer, that returns the appropriate random value
72+
if self.fn is not None:
73+
if self.args is not None:
74+
self.randomizer = partial(self.fn, *self.args)
75+
else:
76+
self.randomizer = self.fn
77+
elif self.bits is not None:
78+
self.randomizer = partial(self.random.getrandbits, self.bits)
79+
self.domain = range(0, 1 << self.bits)
80+
else:
81+
# If we are provided a sufficiently small domain and we have constraints, simply construct a
82+
# constraint solution problem instead.
83+
is_range = isinstance(self.domain, range)
84+
is_list = isinstance(self.domain, list) or isinstance(self.domain, tuple)
85+
is_dict = isinstance(self.domain, dict)
86+
if self.check_constraints and len(self.domain) < self.max_domain_size and (is_range or is_list):
87+
problem = constraint.Problem()
88+
problem.addVariable(self.name, self.domain)
89+
for con in self.constraints:
90+
problem.addConstraint(con, (self.name,))
91+
# Produces a list of dictionaries
92+
solutions = problem.getSolutions()
93+
def solution_picker(solns):
94+
return self.random.choice(solns)[self.name]
95+
self.randomizer = partial(solution_picker, solutions)
96+
self.check_constraints = False
97+
elif is_range:
98+
self.randomizer = partial(self.random.randrange, self.domain.start, self.domain.stop)
99+
elif is_list:
100+
self.randomizer = partial(self.random.choice, self.domain)
101+
elif is_dict:
102+
self.randomizer = partial(self.random.dist, self.domain)
103+
else:
104+
raise TypeError(f'RandVar was passed a domain of a bad type - {self.domain}. Domain should be a range, list, tuple or dictionary.')
105+
106+
def randomize(self) -> Any:
107+
'''
108+
Returns a random value based on the definition of this random variable.
109+
Does not modify the state of the :class:`RandVar` instance.
110+
111+
:return: A randomly generated value, conforming to the definition of
112+
this random variable, its constraints, etc.
113+
:raises RuntimeError: When the problem cannot be solved in fewer than
114+
the allowed number of iterations.
115+
'''
116+
value = self.randomizer()
117+
value_valid = not self.check_constraints
118+
iterations = 0
119+
while not value_valid:
120+
if iterations == self.max_iterations:
121+
raise RuntimeError("Too many iterations, can't solve problem")
122+
problem = constraint.Problem()
123+
problem.addVariable(self.name, (value,))
124+
for con in self.constraints:
125+
problem.addConstraint(con, (self.name,))
126+
value_valid = problem.getSolution() is not None
127+
if not value_valid:
128+
value = self.randomizer()
129+
iterations += 1
130+
return value

0 commit comments

Comments
 (0)