python-constraint2 2.5.0__cp314-cp314-win_amd64.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,1572 @@
1
+ """Module containing the code for constraint definitions."""
2
+
3
+ from constraint.domain import Unassigned
4
+ from typing import Callable, Union, Optional
5
+ from collections.abc import Sequence
6
+ from itertools import product
7
+
8
+ class Constraint:
9
+ """Abstract base class for constraints."""
10
+
11
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False):
12
+ """Perform the constraint checking.
13
+
14
+ If the forwardcheck parameter is not false, besides telling if
15
+ the constraint is currently broken or not, the constraint
16
+ implementation may choose to hide values from the domains of
17
+ unassigned variables to prevent them from being used, and thus
18
+ prune the search space.
19
+
20
+ Args:
21
+ variables (sequence): :py:class:`Variables` affected by that constraint,
22
+ in the same order provided by the user
23
+ domains (dict): Dictionary mapping variables to their
24
+ domains
25
+ assignments (dict): Dictionary mapping assigned variables to
26
+ their current assumed value
27
+ forwardcheck: Boolean value stating whether forward checking
28
+ should be performed or not
29
+
30
+ Returns:
31
+ bool: Boolean value stating if this constraint is currently
32
+ broken or not
33
+ """
34
+ return True
35
+
36
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict):
37
+ """Preprocess variable domains.
38
+
39
+ This method is called before starting to look for solutions,
40
+ and is used to prune domains with specific constraint logic
41
+ when possible. For instance, any constraints with a single
42
+ variable may be applied on all possible values and removed,
43
+ since they may act on individual values even without further
44
+ knowledge about other assignments.
45
+
46
+ Args:
47
+ variables (sequence): Variables affected by that constraint,
48
+ in the same order provided by the user
49
+ domains (dict): Dictionary mapping variables to their
50
+ domains
51
+ constraints (list): List of pairs of (constraint, variables)
52
+ vconstraints (dict): Dictionary mapping variables to a list
53
+ of constraints affecting the given variables.
54
+ """
55
+ if len(variables) == 1:
56
+ variable = variables[0]
57
+ domain = domains[variable]
58
+ for value in domain[:]:
59
+ if not self(variables, domains, {variable: value}):
60
+ domain.remove(value)
61
+ constraints.remove((self, variables))
62
+ vconstraints[variable].remove((self, variables))
63
+
64
+ def forwardCheck(self, variables: Sequence, domains: dict, assignments: dict, _unassigned=Unassigned):
65
+ """Helper method for generic forward checking.
66
+
67
+ Currently, this method acts only when there's a single
68
+ unassigned variable.
69
+
70
+ Args:
71
+ variables (sequence): Variables affected by that constraint,
72
+ in the same order provided by the user
73
+ domains (dict): Dictionary mapping variables to their
74
+ domains
75
+ assignments (dict): Dictionary mapping assigned variables to
76
+ their current assumed value
77
+
78
+ Returns:
79
+ bool: Boolean value stating if this constraint is currently
80
+ broken or not
81
+ """
82
+ unassignedvariable = _unassigned
83
+ for variable in variables:
84
+ if variable not in assignments:
85
+ if unassignedvariable is _unassigned:
86
+ unassignedvariable = variable
87
+ else:
88
+ break
89
+ else:
90
+ if unassignedvariable is not _unassigned:
91
+ # Remove from the unassigned variable domain's all
92
+ # values which break our variable's constraints.
93
+ domain = domains[unassignedvariable]
94
+ if domain:
95
+ for value in domain[:]:
96
+ assignments[unassignedvariable] = value
97
+ if not self(variables, domains, assignments):
98
+ domain.hideValue(value)
99
+ del assignments[unassignedvariable]
100
+ if not domain:
101
+ return False
102
+ return True
103
+
104
+
105
+ class FunctionConstraint(Constraint):
106
+ """Constraint which wraps a function defining the constraint logic.
107
+
108
+ Examples:
109
+ >>> problem = Problem()
110
+ >>> problem.addVariables(["a", "b"], [1, 2])
111
+ >>> def func(a, b):
112
+ ... return b > a
113
+ >>> problem.addConstraint(func, ["a", "b"])
114
+ >>> problem.getSolution()
115
+ {'a': 1, 'b': 2}
116
+
117
+ >>> problem = Problem()
118
+ >>> problem.addVariables(["a", "b"], [1, 2])
119
+ >>> def func(a, b):
120
+ ... return b > a
121
+ >>> problem.addConstraint(FunctionConstraint(func), ["a", "b"])
122
+ >>> problem.getSolution()
123
+ {'a': 1, 'b': 2}
124
+ >>> problem = Problem()
125
+ >>> problem.addVariables([1, 2], ["a", "b"])
126
+ >>> def func(x, y):
127
+ ... return x != y
128
+ >>> problem.addConstraint(FunctionConstraint(func), [1, 2])
129
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
130
+ [[(1, 'a'), (2, 'b')], [(1, 'b'), (2, 'a')]]
131
+ """
132
+
133
+ def __init__(self, func: Callable, assigned: bool = True):
134
+ """Initialization method.
135
+
136
+ Args:
137
+ func (callable object): Function wrapped and queried for
138
+ constraint logic
139
+ assigned (bool): Whether the function may receive unassigned
140
+ variables or not
141
+ """
142
+ self._func = func
143
+ self._assigned = assigned
144
+
145
+ def __call__( # noqa: D102
146
+ self,
147
+ variables: Sequence,
148
+ domains: dict,
149
+ assignments: dict,
150
+ forwardcheck=False,
151
+ _unassigned=Unassigned,
152
+ ):
153
+ # # initial code: 0.94621 seconds, Cythonized: 0.92805 seconds
154
+ # parms = [assignments.get(x, _unassigned) for x in variables]
155
+ # missing = parms.count(_unassigned)
156
+
157
+ # # list comprehension and sum: 0.13744 seconds, Cythonized: 0.10059 seconds
158
+ # parms = [assignments.get(x, _unassigned) for x in variables]
159
+ # missing = sum(x not in assignments for x in variables)
160
+
161
+ # # sum check with fallback: , Cythonized: 0.10108 seconds
162
+ # missing = sum(x not in assignments for x in variables)
163
+ # parms = [assignments.get(x, _unassigned) for x in variables] if missing > 0 else [assignments[x] for x in var]
164
+
165
+ # # tuple list comprehension with unzipping: 0.14521 seconds, Cythonized: 0.12054 seconds
166
+ # lst = [(assignments[x], 0) if x in assignments else (_unassigned, 1) for x in variables]
167
+ # parms, missing_iter = zip(*lst)
168
+ # parms = list(parms)
169
+ # missing = sum(missing_iter)
170
+
171
+ # # single loop array: 0.11249 seconds, Cythonized: 0.09514 seconds
172
+ # parms = [None] * len(variables)
173
+ # missing = 0
174
+ # for i, x in enumerate(variables):
175
+ # if x in assignments:
176
+ # parms[i] = assignments[x]
177
+ # else:
178
+ # parms[i] = _unassigned
179
+ # missing += 1
180
+
181
+ # single loop list: 0.11462 seconds, Cythonized: 0.08686 seconds
182
+ parms = list()
183
+ missing = 0
184
+ for x in variables:
185
+ if x in assignments:
186
+ parms.append(assignments[x])
187
+ else:
188
+ parms.append(_unassigned)
189
+ missing += 1
190
+
191
+ # if there are unassigned variables, do a forward check before executing the restriction function
192
+ if missing > 0:
193
+ return (self._assigned or self._func(*parms)) and (
194
+ not forwardcheck or missing != 1 or self.forwardCheck(variables, domains, assignments)
195
+ )
196
+ return self._func(*parms)
197
+
198
+ class CompilableFunctionConstraint(Constraint):
199
+ """Wrapper function for picklable string constraints that must be compiled into a FunctionConstraint later on."""
200
+
201
+ def __init__(self, func: str, assigned: bool = True): # noqa: D102, D107
202
+ self._func = func
203
+ self._assigned = assigned
204
+
205
+ def __call__(self, variables, domains, assignments, forwardcheck=False, _unassigned=Unassigned): # noqa: D102
206
+ raise NotImplementedError("CompilableFunctionConstraint can not be called directly")
207
+
208
+
209
+ class AllDifferentConstraint(Constraint):
210
+ """Constraint enforcing that values of all given variables are different.
211
+
212
+ Example:
213
+ >>> problem = Problem()
214
+ >>> problem.addVariables(["a", "b"], [1, 2])
215
+ >>> problem.addConstraint(AllDifferentConstraint())
216
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
217
+ [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)]]
218
+ """
219
+
220
+ def __call__( # noqa: D102
221
+ self,
222
+ variables: Sequence,
223
+ domains: dict,
224
+ assignments: dict,
225
+ forwardcheck=False,
226
+ _unassigned=Unassigned,
227
+ ):
228
+ seen = {}
229
+ for variable in variables:
230
+ value = assignments.get(variable, _unassigned)
231
+ if value is not _unassigned:
232
+ if value in seen:
233
+ return False
234
+ seen[value] = True
235
+ if forwardcheck:
236
+ for variable in variables:
237
+ if variable not in assignments:
238
+ domain = domains[variable]
239
+ for value in seen:
240
+ if value in domain:
241
+ domain.hideValue(value)
242
+ if not domain:
243
+ return False
244
+ return True
245
+
246
+
247
+ class AllEqualConstraint(Constraint):
248
+ """Constraint enforcing that values of all given variables are equal.
249
+
250
+ Example:
251
+ >>> problem = Problem()
252
+ >>> problem.addVariables(["a", "b"], [1, 2])
253
+ >>> problem.addConstraint(AllEqualConstraint())
254
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
255
+ [[('a', 1), ('b', 1)], [('a', 2), ('b', 2)]]
256
+ """
257
+
258
+ def __call__( # noqa: D102
259
+ self,
260
+ variables: Sequence,
261
+ domains: dict,
262
+ assignments: dict,
263
+ forwardcheck=False,
264
+ _unassigned=Unassigned,
265
+ ):
266
+ singlevalue = _unassigned
267
+ for variable in variables:
268
+ value = assignments.get(variable, _unassigned)
269
+ if singlevalue is _unassigned:
270
+ singlevalue = value
271
+ elif value is not _unassigned and value != singlevalue:
272
+ return False
273
+ if forwardcheck and singlevalue is not _unassigned:
274
+ for variable in variables:
275
+ if variable not in assignments:
276
+ domain = domains[variable]
277
+ if singlevalue not in domain:
278
+ return False
279
+ for value in domain[:]:
280
+ if value != singlevalue:
281
+ domain.hideValue(value)
282
+ return True
283
+
284
+
285
+ class ExactSumConstraint(Constraint):
286
+ """Constraint enforcing that values of given variables sum exactly to a given amount.
287
+
288
+ Example:
289
+ >>> problem = Problem()
290
+ >>> problem.addVariables(["a", "b"], [1, 2])
291
+ >>> problem.addConstraint(ExactSumConstraint(3))
292
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
293
+ [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)]]
294
+ >>> problem = Problem()
295
+ >>> problem.addVariables(["a", "b"], [-1, 0, 1])
296
+ >>> problem.addConstraint(ExactSumConstraint(0))
297
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
298
+ [[('a', -1), ('b', 1)], [('a', 0), ('b', 0)], [('a', 1), ('b', -1)]]
299
+ """
300
+
301
+ def __init__(self, exactsum: Union[int, float], multipliers: Optional[Sequence] = None):
302
+ """Initialization method.
303
+
304
+ Args:
305
+ exactsum (number): Value to be considered as the exact sum
306
+ multipliers (sequence of numbers): If given, variable values
307
+ will be multiplied by the given factors before being
308
+ summed to be checked
309
+ """
310
+ self._exactsum = exactsum
311
+ self._multipliers = multipliers
312
+ self._var_max = {}
313
+ self._var_min = {}
314
+ self._var_is_negative = {}
315
+
316
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
317
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
318
+ multipliers = self._multipliers if self._multipliers else [1] * len(variables)
319
+ exactsum = self._exactsum
320
+ self._var_min = { variable: min(domains[variable]) * multiplier for variable, multiplier in zip(variables, multipliers) } # noqa: E501
321
+ self._var_max = { variable: max(domains[variable]) * multiplier for variable, multiplier in zip(variables, multipliers) } # noqa: E501
322
+
323
+ # preprocess the domains to remove values that cannot contribute to the exact sum
324
+ for variable, multiplier in zip(variables, multipliers):
325
+ domain = domains[variable]
326
+ other_vars_min = sum_other_vars(variables, variable, self._var_min)
327
+ other_vars_max = sum_other_vars(variables, variable, self._var_max)
328
+ for value in domain[:]:
329
+ if value * multiplier + other_vars_min > exactsum:
330
+ domain.remove(value)
331
+ if value * multiplier + other_vars_max < exactsum:
332
+ domain.remove(value)
333
+
334
+ # recalculate the min and max after pruning
335
+ self._var_max = { variable: max(domains[variable]) * multiplier if len(domains[variable]) > 0 else 0 for variable, multiplier in zip(variables, multipliers) } # noqa: E501
336
+ self._var_min = { variable: min(domains[variable]) * multiplier if len(domains[variable]) > 0 else 0 for variable, multiplier in zip(variables, multipliers) } # noqa: E501
337
+ self._var_is_negative = { variable: self._var_min[variable] < 0 for variable in variables }
338
+
339
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
340
+ multipliers = self._multipliers
341
+ exactsum = self._exactsum
342
+ sum = 0
343
+ min_sum_missing = 0
344
+ max_sum_missing = 0
345
+ missing = False
346
+ missing_negative = False
347
+ if multipliers:
348
+ for variable, multiplier in zip(variables, multipliers):
349
+ if variable in assignments:
350
+ sum += assignments[variable] * multiplier
351
+ else:
352
+ min_sum_missing += self._var_min[variable]
353
+ max_sum_missing += self._var_max[variable]
354
+ missing = True
355
+ if self._var_is_negative[variable]:
356
+ missing_negative = True
357
+ if isinstance(sum, float):
358
+ sum = round(sum, 10)
359
+ if sum + min_sum_missing > exactsum or sum + max_sum_missing < exactsum:
360
+ return False
361
+ if forwardcheck and missing and not missing_negative:
362
+ for variable, multiplier in zip(variables, multipliers):
363
+ if variable not in assignments:
364
+ domain = domains[variable]
365
+ for value in domain[:]:
366
+ if sum + value * multiplier > exactsum:
367
+ domain.hideValue(value)
368
+ if not domain:
369
+ return False
370
+ else:
371
+ for variable in variables:
372
+ if variable in assignments:
373
+ sum += assignments[variable]
374
+ else:
375
+ min_sum_missing += self._var_min[variable]
376
+ max_sum_missing += self._var_max[variable]
377
+ missing = True
378
+ if self._var_is_negative[variable]:
379
+ missing_negative = True
380
+ if isinstance(sum, float):
381
+ sum = round(sum, 10)
382
+ if sum + min_sum_missing > exactsum or sum + max_sum_missing < exactsum:
383
+ return False
384
+ if forwardcheck and missing and not missing_negative:
385
+ for variable in variables:
386
+ if variable not in assignments:
387
+ domain = domains[variable]
388
+ for value in domain[:]:
389
+ if sum + value > exactsum:
390
+ domain.hideValue(value)
391
+ if not domain:
392
+ return False
393
+ if missing:
394
+ return sum + min_sum_missing <= exactsum and sum + max_sum_missing >= exactsum
395
+ else:
396
+ return sum == exactsum
397
+
398
+ class VariableExactSumConstraint(Constraint):
399
+ """Constraint enforcing that the sum of variables equals the value of another variable.
400
+
401
+ Example:
402
+ >>> problem = Problem()
403
+ >>> problem.addVariables(["a", "b", "c"], [1, 2, 3])
404
+ >>> problem.addConstraint(VariableExactSumConstraint('c', ['a', 'b']))
405
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
406
+ [[('a', 1), ('b', 1), ('c', 2)], [('a', 1), ('b', 2), ('c', 3)], [('a', 2), ('b', 1), ('c', 3)]]
407
+ >>> problem = Problem()
408
+ >>> problem.addVariable('a', [-1,0,1])
409
+ >>> problem.addVariable('b', [-1,0,1])
410
+ >>> problem.addVariable('c', [0, 2])
411
+ >>> problem.addConstraint(VariableExactSumConstraint('c', ['a', 'b']))
412
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
413
+ [[('a', -1), ('b', 1), ('c', 0)], [('a', 0), ('b', 0), ('c', 0)], [('a', 1), ('b', -1), ('c', 0)], [('a', 1), ('b', 1), ('c', 2)]]
414
+ """ # noqa: E501
415
+
416
+ def __init__(self, target_var: str, sum_vars: Sequence[str], multipliers: Optional[Sequence] = None):
417
+ """Initialization method.
418
+
419
+ Args:
420
+ target_var (Variable): The target variable to sum to.
421
+ sum_vars (sequence of Variables): The variables to sum up.
422
+ multipliers (sequence of numbers): If given, variable values
423
+ (except the last) will be multiplied by the given factors before being
424
+ summed to match the last variable.
425
+ """
426
+ self.target_var = target_var
427
+ self.sum_vars = sum_vars
428
+ self._multipliers = multipliers
429
+
430
+ if multipliers:
431
+ assert len(multipliers) == len(sum_vars) + 1, "Multipliers must match sum variables and +1 for target."
432
+ assert all(isinstance(m, (int, float)) for m in multipliers), "Multipliers must be numbers."
433
+ assert multipliers[-1] == 1, "Last multiplier must be 1, as it is the target variable."
434
+
435
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
436
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
437
+
438
+ multipliers = self._multipliers
439
+
440
+ if multipliers:
441
+ for var, multiplier in zip(self.sum_vars, multipliers):
442
+ domain = domains[var]
443
+ for value in domain[:]:
444
+ if value * multiplier > max(domains[self.target_var]):
445
+ domain.remove(value)
446
+ else:
447
+ for var in self.sum_vars:
448
+ domain = domains[var]
449
+ others_min = sum(min(domains[v]) for v in self.sum_vars if v != var)
450
+ others_max = sum(max(domains[v]) for v in self.sum_vars if v != var)
451
+ for value in domain[:]:
452
+ if value + others_min > max(domains[self.target_var]):
453
+ domain.remove(value)
454
+ if value + others_max < min(domains[self.target_var]):
455
+ domain.remove(value)
456
+
457
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
458
+ multipliers = self._multipliers
459
+
460
+ if self.target_var not in assignments:
461
+ return True # can't evaluate without target, defer to later
462
+
463
+ target_value = assignments[self.target_var]
464
+ sum_value = 0
465
+ missing = False
466
+
467
+ if multipliers:
468
+ for var, multiplier in zip(self.sum_vars, multipliers):
469
+ if var in assignments:
470
+ sum_value += assignments[var] * multiplier
471
+ else:
472
+ missing = True
473
+ else:
474
+ for var in self.sum_vars:
475
+ if var in assignments:
476
+ sum_value += assignments[var]
477
+ else:
478
+ sum_value += min(domains[var]) # use min value if not assigned
479
+ missing = True
480
+
481
+ if isinstance(sum_value, float):
482
+ sum_value = round(sum_value, 10)
483
+
484
+ if missing:
485
+ # Partial assignments: only check feasibility
486
+ if sum_value > target_value:
487
+ return False
488
+ if forwardcheck:
489
+ for var in self.sum_vars:
490
+ if var not in assignments:
491
+ domain = domains[var]
492
+ if multipliers:
493
+ for value in domain[:]:
494
+ temp_sum = sum_value + (value * multipliers[self.sum_vars.index(var)])
495
+ if temp_sum > target_value:
496
+ domain.hideValue(value)
497
+ else:
498
+ for value in domain[:]:
499
+ temp_sum = sum_value + value
500
+ if temp_sum > target_value:
501
+ domain.hideValue(value)
502
+ if not domain:
503
+ return False
504
+ return True
505
+ else:
506
+ return sum_value == target_value
507
+
508
+
509
+ class MinSumConstraint(Constraint):
510
+ """Constraint enforcing that values of given variables sum at least to a given amount.
511
+
512
+ Example:
513
+ >>> problem = Problem()
514
+ >>> problem.addVariables(["a", "b"], [1, 2])
515
+ >>> problem.addConstraint(MinSumConstraint(3))
516
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
517
+ [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)], [('a', 2), ('b', 2)]]
518
+ >>> problem = Problem()
519
+ >>> problem.addVariables(["a", "b"], [-3, 1])
520
+ >>> problem.addConstraint(MinSumConstraint(-2))
521
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
522
+ [[('a', -3), ('b', 1)], [('a', 1), ('b', -3)], [('a', 1), ('b', 1)]]
523
+ """
524
+
525
+ def __init__(self, minsum: Union[int, float], multipliers: Optional[Sequence] = None):
526
+ """Initialization method.
527
+
528
+ Args:
529
+ minsum (number): Value to be considered as the minimum sum
530
+ multipliers (sequence of numbers): If given, variable values
531
+ will be multiplied by the given factors before being
532
+ summed to be checked
533
+ """
534
+ self._minsum = minsum
535
+ self._multipliers = multipliers
536
+ self._var_max = {}
537
+
538
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
539
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
540
+ multipliers = self._multipliers if self._multipliers else [1] * len(variables)
541
+ self._var_max = { variable: max(domains[variable]) * multiplier for variable, multiplier in zip(variables, multipliers) } # noqa: E501
542
+
543
+ # preprocess the domains to remove values that cannot contribute to the minimum sum
544
+ for variable, multiplier in zip(variables, multipliers):
545
+ domain = domains[variable]
546
+ others_max = sum_other_vars(variables, variable, self._var_max)
547
+ for value in domain[:]:
548
+ if value * multiplier + others_max < self._minsum:
549
+ domain.remove(value)
550
+
551
+ # recalculate the max after pruning
552
+ self._var_max = { variable: max(domains[variable]) * multiplier if len(domains[variable]) > 0 else 0 for variable, multiplier in zip(variables, multipliers) } # noqa: E501
553
+
554
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
555
+ multipliers = self._multipliers
556
+ minsum = self._minsum
557
+ sum = 0
558
+ missing = False
559
+ max_sum_missing = 0
560
+ if multipliers:
561
+ for variable, multiplier in zip(variables, multipliers):
562
+ if variable in assignments:
563
+ sum += assignments[variable] * multiplier
564
+ else:
565
+ max_sum_missing += self._var_max[variable]
566
+ missing = True
567
+ else:
568
+ for variable in variables:
569
+ if variable in assignments:
570
+ sum += assignments[variable]
571
+ else:
572
+ max_sum_missing += self._var_max[variable]
573
+ missing = True
574
+
575
+ if isinstance(sum, float):
576
+ sum = round(sum, 10)
577
+ if sum + max_sum_missing < minsum:
578
+ return False
579
+ return sum >= minsum or missing
580
+
581
+ class VariableMinSumConstraint(Constraint):
582
+ """Constraint enforcing that the sum of variables sum at least to the value of another variable.
583
+
584
+ Example:
585
+ >>> problem = Problem()
586
+ >>> problem.addVariables(["a", "b", "c"], [1, 4])
587
+ >>> problem.addConstraint(VariableMinSumConstraint('c', ['a', 'b']))
588
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
589
+ [[('a', 1), ('b', 1), ('c', 1)], [('a', 1), ('b', 4), ('c', 1)], [('a', 1), ('b', 4), ('c', 4)], [('a', 4), ('b', 1), ('c', 1)], [('a', 4), ('b', 1), ('c', 4)], [('a', 4), ('b', 4), ('c', 1)], [('a', 4), ('b', 4), ('c', 4)]]
590
+ >>> problem = Problem()
591
+ >>> problem.addVariables(["a", "b"], [-3, 1])
592
+ >>> problem.addVariable('c', [-2, 2])
593
+ >>> problem.addConstraint(VariableMinSumConstraint('c', ['a', 'b']))
594
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
595
+ [[('a', -3), ('b', 1), ('c', -2)], [('a', 1), ('b', -3), ('c', -2)], [('a', 1), ('b', 1), ('c', -2)], [('a', 1), ('b', 1), ('c', 2)]]
596
+ """ # noqa: E501
597
+
598
+ def __init__(self, target_var: str, sum_vars: Sequence[str], multipliers: Optional[Sequence] = None):
599
+ """Initialization method.
600
+
601
+ Args:
602
+ target_var (Variable): The target variable to sum to.
603
+ sum_vars (sequence of Variables): The variables to sum up.
604
+ multipliers (sequence of numbers): If given, variable values
605
+ (except the last) will be multiplied by the given factors before being
606
+ summed to match the last variable.
607
+ """
608
+ self.target_var = target_var
609
+ self.sum_vars = sum_vars
610
+ self._multipliers = multipliers
611
+
612
+ if multipliers:
613
+ assert len(multipliers) == len(sum_vars) + 1, "Multipliers must match sum variables and +1 for target."
614
+ assert all(isinstance(m, (int, float)) for m in multipliers), "Multipliers must be numbers."
615
+ assert multipliers[-1] == 1, "Last multiplier must be 1, as it is the target variable."
616
+
617
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
618
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
619
+
620
+ multipliers = self._multipliers
621
+
622
+ if not multipliers:
623
+ for var in self.sum_vars:
624
+ domain = domains[var]
625
+ others_max = sum(max(domains[v]) for v in self.sum_vars if v != var)
626
+ for value in domain[:]:
627
+ if value + others_max < min(domains[self.target_var]):
628
+ domain.remove(value)
629
+
630
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
631
+ multipliers = self._multipliers
632
+
633
+ if self.target_var not in assignments:
634
+ return True # can't evaluate without target, defer to later
635
+
636
+ target_value = assignments[self.target_var]
637
+ sum_value = 0
638
+
639
+ if multipliers:
640
+ for var, multiplier in zip(self.sum_vars, multipliers):
641
+ if var in assignments:
642
+ sum_value += assignments[var] * multiplier
643
+ else:
644
+ sum_value += max(domains[var] * multiplier) # use max value if not assigned
645
+ else:
646
+ for var in self.sum_vars:
647
+ if var in assignments:
648
+ sum_value += assignments[var]
649
+ else:
650
+ sum_value += max(domains[var]) # use max value if not assigned
651
+
652
+ if isinstance(sum_value, float):
653
+ sum_value = round(sum_value, 10)
654
+
655
+ return sum_value >= target_value
656
+
657
+
658
+ class MaxSumConstraint(Constraint):
659
+ """Constraint enforcing that values of given variables sum up to a given amount.
660
+
661
+ Example:
662
+ >>> problem = Problem()
663
+ >>> problem.addVariables(["a", "b"], [1, 2])
664
+ >>> problem.addConstraint(MaxSumConstraint(3))
665
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
666
+ [[('a', 1), ('b', 1)], [('a', 1), ('b', 2)], [('a', 2), ('b', 1)]]
667
+ >>> problem = Problem()
668
+ >>> problem.addVariables(["a", "b"], [-3, 1])
669
+ >>> problem.addConstraint(MaxSumConstraint(-2))
670
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
671
+ [[('a', -3), ('b', -3)], [('a', -3), ('b', 1)], [('a', 1), ('b', -3)]]
672
+ """
673
+
674
+ def __init__(self, maxsum: Union[int, float], multipliers: Optional[Sequence] = None):
675
+ """Initialization method.
676
+
677
+ Args:
678
+ maxsum (number): Value to be considered as the maximum sum
679
+ multipliers (sequence of numbers): If given, variable values
680
+ will be multiplied by the given factors before being
681
+ summed to be checked
682
+ """
683
+ self._maxsum = maxsum
684
+ self._multipliers = multipliers
685
+ self._var_min = {}
686
+ self._var_is_negative = {}
687
+
688
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
689
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
690
+ multipliers = self._multipliers if self._multipliers else [1] * len(variables)
691
+ maxsum = self._maxsum
692
+ self._var_min = { variable: min(domains[variable]) * multiplier for variable, multiplier in zip(variables, multipliers) } # noqa: E501
693
+
694
+ # preprocess the domains to remove values that cannot contribute to the sum
695
+ for variable, multiplier in zip(variables, multipliers):
696
+ domain = domains[variable]
697
+ other_vars_min = sum_other_vars(variables, variable, self._var_min)
698
+ for value in domain[:]:
699
+ if value * multiplier + other_vars_min > maxsum:
700
+ domain.remove(value)
701
+
702
+ # recalculate the min after pruning
703
+ self._var_min = { variable: min(domains[variable]) * multiplier if len(domains[variable]) > 0 else 0 for variable, multiplier in zip(variables, multipliers) } # noqa: E501
704
+ self._var_is_negative = { variable: self._var_min[variable] < 0 for variable in variables }
705
+
706
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
707
+ multipliers = self._multipliers
708
+ maxsum = self._maxsum
709
+ sum = 0
710
+ min_sum_missing = 0
711
+ missing = False
712
+ missing_negative = False
713
+ if multipliers:
714
+ for variable, multiplier in zip(variables, multipliers):
715
+ if variable in assignments:
716
+ sum += assignments[variable] * multiplier
717
+ else:
718
+ min_sum_missing += self._var_min[variable]
719
+ missing = True
720
+ if self._var_is_negative[variable]:
721
+ missing_negative = True
722
+ if isinstance(sum, float):
723
+ sum = round(sum, 10)
724
+ if sum + min_sum_missing > maxsum:
725
+ return False
726
+ if forwardcheck and missing and not missing_negative:
727
+ for variable, multiplier in zip(variables, multipliers):
728
+ if variable not in assignments:
729
+ domain = domains[variable]
730
+ for value in domain[:]:
731
+ if sum + value * multiplier > maxsum:
732
+ domain.hideValue(value)
733
+ if not domain:
734
+ return False
735
+ else:
736
+ for variable in variables:
737
+ if variable in assignments:
738
+ sum += assignments[variable]
739
+ else:
740
+ min_sum_missing += self._var_min[variable]
741
+ missing = True
742
+ if self._var_is_negative[variable]:
743
+ missing_negative = True
744
+ if isinstance(sum, float):
745
+ sum = round(sum, 10)
746
+ if sum + min_sum_missing > maxsum:
747
+ return False
748
+ if forwardcheck and missing and not missing_negative:
749
+ for variable in variables:
750
+ if variable not in assignments:
751
+ domain = domains[variable]
752
+ for value in domain[:]:
753
+ if sum + value > maxsum:
754
+ domain.hideValue(value)
755
+ if not domain:
756
+ return False
757
+ return True
758
+
759
+
760
+ class VariableMaxSumConstraint(Constraint):
761
+ """Constraint enforcing that the sum of variables sum at most to the value of another variable.
762
+
763
+ Example:
764
+ >>> problem = Problem()
765
+ >>> problem.addVariables(["a", "b", "c"], [1, 3, 4])
766
+ >>> problem.addConstraint(VariableMaxSumConstraint('c', ['a', 'b']))
767
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
768
+ [[('a', 1), ('b', 1), ('c', 3)], [('a', 1), ('b', 1), ('c', 4)], [('a', 1), ('b', 3), ('c', 4)], [('a', 3), ('b', 1), ('c', 4)]]
769
+ >>> problem = Problem()
770
+ >>> problem.addVariables(["a", "b"], [-2, 1])
771
+ >>> problem.addVariable('c', [-3, -1])
772
+ >>> problem.addConstraint(VariableMaxSumConstraint('c', ['a', 'b']))
773
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
774
+ [[('a', -2), ('b', -2), ('c', -3)], [('a', -2), ('b', -2), ('c', -1)], [('a', -2), ('b', 1), ('c', -1)], [('a', 1), ('b', -2), ('c', -1)]]
775
+ """ # noqa: E501
776
+
777
+ def __init__(self, target_var: str, sum_vars: Sequence[str], multipliers: Optional[Sequence] = None):
778
+ """Initialization method.
779
+
780
+ Args:
781
+ target_var (Variable): The target variable to sum to.
782
+ sum_vars (sequence of Variables): The variables to sum up.
783
+ multipliers (sequence of numbers): If given, variable values
784
+ (except the last) will be multiplied by the given factors before being
785
+ summed to match the last variable.
786
+ """
787
+ self.target_var = target_var
788
+ self.sum_vars = sum_vars
789
+ self._multipliers = multipliers
790
+
791
+ if multipliers:
792
+ assert len(multipliers) == len(sum_vars) + 1, "Multipliers must match sum variables and +1 for target."
793
+ assert all(isinstance(m, (int, float)) for m in multipliers), "Multipliers must be numbers."
794
+ assert multipliers[-1] == 1, "Last multiplier must be 1, as it is the target variable."
795
+
796
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
797
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
798
+
799
+ multipliers = self._multipliers
800
+
801
+ if not multipliers:
802
+ for var in self.sum_vars:
803
+ domain = domains[var]
804
+ others_min = sum(min(domains[v]) for v in self.sum_vars if v != var)
805
+ for value in domain[:]:
806
+ if value + others_min > max(domains[self.target_var]):
807
+ domain.remove(value)
808
+
809
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
810
+ multipliers = self._multipliers
811
+
812
+ if self.target_var not in assignments:
813
+ return True # can't evaluate without target, defer to later
814
+
815
+ target_value = assignments[self.target_var]
816
+ sum_value = 0
817
+
818
+ if multipliers:
819
+ for var, multiplier in zip(self.sum_vars, multipliers):
820
+ if var in assignments:
821
+ sum_value += assignments[var] * multiplier
822
+ else:
823
+ sum_value += min(domains[var] * multiplier) # use min value if not assigned
824
+ else:
825
+ for var in self.sum_vars:
826
+ if var in assignments:
827
+ sum_value += assignments[var]
828
+ else:
829
+ sum_value += min(domains[var]) # use min value if not assigned
830
+
831
+ if isinstance(sum_value, float):
832
+ sum_value = round(sum_value, 10)
833
+
834
+ return sum_value <= target_value
835
+
836
+
837
+ class ExactProdConstraint(Constraint):
838
+ """Constraint enforcing that values of given variables create a product of exactly a given amount.
839
+
840
+ Example:
841
+ >>> problem = Problem()
842
+ >>> problem.addVariables(["a", "b"], [1, 2])
843
+ >>> problem.addConstraint(ExactProdConstraint(2))
844
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
845
+ [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)]]
846
+ >>> problem = Problem()
847
+ >>> problem.addVariables(["a", "b"], [-2, -1, 1, 2])
848
+ >>> problem.addConstraint(ExactProdConstraint(-2))
849
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
850
+ [[('a', -2), ('b', 1)], [('a', -1), ('b', 2)], [('a', 1), ('b', -2)], [('a', 2), ('b', -1)]]
851
+ """
852
+
853
+ def __init__(self, exactprod: Union[int, float]):
854
+ """Instantiate an ExactProdConstraint.
855
+
856
+ Args:
857
+ exactprod: Value to be considered as the product
858
+ """
859
+ self._exactprod = exactprod
860
+ self._variable_contains_lt1: list[bool] = list()
861
+
862
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
863
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
864
+
865
+ # check if there are any values less than 1 in the associated variables
866
+ self._variable_contains_lt1: list[bool] = list()
867
+ variable_with_lt1 = None
868
+ for variable in variables:
869
+ contains_lt1 = any(value < 1 for value in domains[variable])
870
+ self._variable_contains_lt1.append(contains_lt1)
871
+ for variable, contains_lt1 in zip(variables, self._variable_contains_lt1):
872
+ if contains_lt1 is True:
873
+ if variable_with_lt1 is not None:
874
+ # if more than one associated variables contain less than 1, we can't prune
875
+ return
876
+ variable_with_lt1 = variable
877
+
878
+ # prune the associated variables of values > exactprod
879
+ exactprod = self._exactprod
880
+ for variable in variables:
881
+ if variable_with_lt1 is not None and variable_with_lt1 != variable:
882
+ continue
883
+ domain = domains[variable]
884
+ for value in domain[:]:
885
+ if value > exactprod:
886
+ domain.remove(value)
887
+ elif value == 0 and exactprod != 0:
888
+ domain.remove(value)
889
+
890
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
891
+ exactprod = self._exactprod
892
+ prod = 1
893
+ missing = False
894
+ missing_lt1 = []
895
+ # find out which variables contain values less than 1 if not preprocessed
896
+ if len(self._variable_contains_lt1) != len(variables):
897
+ for variable in variables:
898
+ self._variable_contains_lt1.append(any(value < 1 for value in domains[variable]))
899
+ for variable, contains_lt1 in zip(variables, self._variable_contains_lt1):
900
+ if variable in assignments:
901
+ prod *= assignments[variable]
902
+ else:
903
+ missing = True
904
+ if contains_lt1:
905
+ missing_lt1.append(variable)
906
+ if isinstance(prod, float):
907
+ prod = round(prod, 10)
908
+ if (not missing and prod != exactprod) or (len(missing_lt1) == 0 and prod > exactprod):
909
+ return False
910
+ if forwardcheck:
911
+ for variable in variables:
912
+ if variable not in assignments and (variable not in missing_lt1 or len(missing_lt1) == 1):
913
+ domain = domains[variable]
914
+ for value in domain[:]:
915
+ if prod * value > exactprod:
916
+ domain.hideValue(value)
917
+ if not domain:
918
+ return False
919
+ return True
920
+
921
+ class VariableExactProdConstraint(Constraint):
922
+ """Constraint enforcing that the product of variables equals the value of another variable.
923
+
924
+ Example:
925
+ >>> problem = Problem()
926
+ >>> problem.addVariables(["a", "b", "c"], [1, 2])
927
+ >>> problem.addConstraint(VariableExactProdConstraint('c', ['a', 'b']))
928
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
929
+ [[('a', 1), ('b', 1), ('c', 1)], [('a', 1), ('b', 2), ('c', 2)], [('a', 2), ('b', 1), ('c', 2)]]
930
+ >>> problem = Problem()
931
+ >>> problem.addVariables(["a", "b"], [-2, -1, 2])
932
+ >>> problem.addVariable('c', [-2, 1])
933
+ >>> problem.addConstraint(VariableExactProdConstraint('c', ['a', 'b']))
934
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
935
+ [[('a', -1), ('b', -1), ('c', 1)], [('a', -1), ('b', 2), ('c', -2)], [('a', 2), ('b', -1), ('c', -2)]]
936
+ """
937
+
938
+ def __init__(self, target_var: str, product_vars: Sequence[str]):
939
+ """Instantiate a VariableExactProdConstraint.
940
+
941
+ Args:
942
+ target_var (Variable): The target variable to match.
943
+ product_vars (sequence of Variables): The variables to calculate the product of.
944
+ """
945
+ self.target_var = target_var
946
+ self.product_vars = product_vars
947
+
948
+ def _get_product_bounds(self, domain_dict, exclude_var=None):
949
+ """Return min and max product of domains of product_vars (excluding `exclude_var` if given)."""
950
+ bounds = []
951
+ for var in self.product_vars:
952
+ if var == exclude_var:
953
+ continue
954
+ dom = domain_dict[var]
955
+ if not dom:
956
+ continue
957
+ bounds.append((min(dom), max(dom)))
958
+
959
+ all_bounds = [b for b in bounds]
960
+ if not all_bounds:
961
+ return 1, 1
962
+
963
+ # Get all combinations of min/max to find global min/max product
964
+ candidates = [b for b in product(*[(lo, hi) for lo, hi in all_bounds])]
965
+ products = [self._safe_product(p) for p in candidates]
966
+ return min(products), max(products)
967
+
968
+ def _safe_product(self, values):
969
+ prod = 1
970
+ for v in values:
971
+ prod *= v
972
+ return prod
973
+
974
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
975
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
976
+
977
+ target_domain = domains[self.target_var]
978
+ target_min = min(target_domain)
979
+ target_max = max(target_domain)
980
+ for var in self.product_vars:
981
+ other_min, other_max = self._get_product_bounds(domains, exclude_var=var)
982
+ domain = domains[var]
983
+ for value in domain[:]:
984
+ candidates = [value * other_min, value * other_max]
985
+ minval, maxval = min(candidates), max(candidates)
986
+ if maxval < target_min or minval > target_max:
987
+ domain.remove(value)
988
+
989
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
990
+ if self.target_var not in assignments:
991
+ return True
992
+
993
+ target_value = assignments[self.target_var]
994
+ assigned_product = 1
995
+ unassigned_vars = []
996
+
997
+ for var in self.product_vars:
998
+ if var in assignments:
999
+ assigned_product *= assignments[var]
1000
+ else:
1001
+ unassigned_vars.append(var)
1002
+
1003
+ if isinstance(assigned_product, float):
1004
+ assigned_product = round(assigned_product, 10)
1005
+
1006
+ if not unassigned_vars:
1007
+ return assigned_product == target_value
1008
+
1009
+ # Partial assignment – check feasibility
1010
+ domain_bounds = [(min(domains[v]), max(domains[v])) for v in unassigned_vars]
1011
+ candidates = [self._safe_product(p) for p in product(*[(lo, hi) for lo, hi in domain_bounds])]
1012
+ possible_min = min(assigned_product * c for c in candidates)
1013
+ possible_max = max(assigned_product * c for c in candidates)
1014
+
1015
+ if target_value < possible_min or target_value > possible_max:
1016
+ return False
1017
+
1018
+ # the below forwardcheck is incorrect for mixes of negative and positive values
1019
+ # if forwardcheck:
1020
+ # for var in unassigned_vars:
1021
+ # others = [v for v in unassigned_vars if v != var]
1022
+ # others_bounds = [(min(domains[v]), max(domains[v])) for v in others] or [(1, 1)]
1023
+ # other_products = [self._safe_product(p) for p in product(*[(lo, hi) for lo, hi in others_bounds])]
1024
+
1025
+ # domain = domains[var]
1026
+ # for value in domain[:]:
1027
+ # candidates = [assigned_product * value * p for p in other_products]
1028
+ # if all(c != target_value for c in candidates):
1029
+ # domain.hideValue(value)
1030
+ # if not domain:
1031
+ # return False
1032
+
1033
+ return True
1034
+
1035
+
1036
+ class MinProdConstraint(Constraint):
1037
+ """Constraint enforcing that values of given variables create a product up to at least a given amount.
1038
+
1039
+ Example:
1040
+ >>> problem = Problem()
1041
+ >>> problem.addVariables(["a", "b"], [1, 2])
1042
+ >>> problem.addConstraint(MinProdConstraint(2))
1043
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1044
+ [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)], [('a', 2), ('b', 2)]]
1045
+ >>> problem = Problem()
1046
+ >>> problem.addVariables(["a", "b"], [-2, -1, 1])
1047
+ >>> problem.addConstraint(MinProdConstraint(1))
1048
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1049
+ [[('a', -2), ('b', -2)], [('a', -2), ('b', -1)], [('a', -1), ('b', -2)], [('a', -1), ('b', -1)], [('a', 1), ('b', 1)]]
1050
+ """ # noqa: E501
1051
+
1052
+ def __init__(self, minprod: Union[int, float]):
1053
+ """Instantiate a MinProdConstraint.
1054
+
1055
+ Args:
1056
+ minprod: Value to be considered as the maximum product
1057
+ """
1058
+ self._minprod = minprod
1059
+
1060
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
1061
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
1062
+
1063
+ # prune the associated variables of values > minprod
1064
+ minprod = self._minprod
1065
+ for variable in variables:
1066
+ domain = domains[variable]
1067
+ for value in domain[:]:
1068
+ if value == 0 and minprod > 0:
1069
+ domain.remove(value)
1070
+
1071
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
1072
+ # check if each variable is in the assignments
1073
+ for variable in variables:
1074
+ if variable not in assignments:
1075
+ return True
1076
+
1077
+ # with each variable assigned, sum the values
1078
+ minprod = self._minprod
1079
+ prod = 1
1080
+ for variable in variables:
1081
+ prod *= assignments[variable]
1082
+ if isinstance(prod, float):
1083
+ prod = round(prod, 10)
1084
+ return prod >= minprod
1085
+
1086
+
1087
+ class VariableMinProdConstraint(Constraint):
1088
+ """Constraint enforcing that the product of variables is at least the value of another variable.
1089
+
1090
+ Example:
1091
+ >>> problem = Problem()
1092
+ >>> problem.addVariables(["a", "b", "c"], [-1, 2])
1093
+ >>> problem.addConstraint(VariableMinProdConstraint('c', ['a', 'b']))
1094
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1095
+ [[('a', -1), ('b', -1), ('c', -1)], [('a', 2), ('b', 2), ('c', -1)], [('a', 2), ('b', 2), ('c', 2)]]
1096
+ >>> problem = Problem()
1097
+ >>> problem.addVariables(["a", "b"], [-2, -1, 1])
1098
+ >>> problem.addVariable('c', [2, 5])
1099
+ >>> problem.addConstraint(VariableMinProdConstraint('c', ['a', 'b']))
1100
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1101
+ [[('a', -2), ('b', -2), ('c', 2)], [('a', -2), ('b', -1), ('c', 2)], [('a', -1), ('b', -2), ('c', 2)]]
1102
+ """
1103
+
1104
+ def __init__(self, target_var: str, product_vars: Sequence[str]): # noqa: D107
1105
+ self.target_var = target_var
1106
+ self.product_vars = product_vars
1107
+
1108
+ def _get_product_bounds(self, domain_dict, exclude_var=None):
1109
+ bounds = []
1110
+ for var in self.product_vars:
1111
+ if var == exclude_var:
1112
+ continue
1113
+ dom = domain_dict[var]
1114
+ if not dom:
1115
+ continue
1116
+ bounds.append((min(dom), max(dom)))
1117
+
1118
+ if not bounds:
1119
+ return 1, 1
1120
+
1121
+ # Try all corner combinations
1122
+ candidates = [p for p in product(*[(lo, hi) for lo, hi in bounds])]
1123
+ products = [self._safe_product(p) for p in candidates]
1124
+ return min(products), max(products)
1125
+
1126
+ def _safe_product(self, values):
1127
+ prod = 1
1128
+ for v in values:
1129
+ prod *= v
1130
+ return prod
1131
+
1132
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
1133
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
1134
+ target_dom = domains[self.target_var]
1135
+ t_min = min(target_dom)
1136
+
1137
+ for var in self.product_vars:
1138
+ min_others, max_others = self._get_product_bounds(domains, exclude_var=var)
1139
+ dom = domains[var]
1140
+ for val in dom[:]:
1141
+ possible_prods = [val * min_others, val * max_others]
1142
+ if max(possible_prods) < t_min:
1143
+ dom.remove(val)
1144
+
1145
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
1146
+ if self.target_var not in assignments:
1147
+ return True # Can't evaluate yet
1148
+
1149
+ target_value = assignments[self.target_var]
1150
+ assigned_prod = 1
1151
+ unassigned = []
1152
+
1153
+ for var in self.product_vars:
1154
+ if var in assignments:
1155
+ assigned_prod *= assignments[var]
1156
+ else:
1157
+ unassigned.append(var)
1158
+
1159
+ if not unassigned:
1160
+ return assigned_prod >= target_value
1161
+
1162
+ # Estimate min possible value of full product
1163
+ domain_bounds = [(min(domains[v]), max(domains[v])) for v in unassigned]
1164
+ candidates = [self._safe_product(p) for p in product(*[(lo, hi) for lo, hi in domain_bounds])]
1165
+ possible_prods = [assigned_prod * c for c in candidates]
1166
+ if max(possible_prods) < target_value:
1167
+ return False
1168
+
1169
+ if forwardcheck:
1170
+ for var in unassigned:
1171
+ other_unassigned = [v for v in unassigned if v != var]
1172
+ if other_unassigned:
1173
+ bounds = [(min(domains[v]), max(domains[v])) for v in other_unassigned]
1174
+ other_products = [self._safe_product(p) for p in product(*[(lo, hi) for lo, hi in bounds])]
1175
+ else:
1176
+ other_products = [1]
1177
+
1178
+ domain = domains[var]
1179
+ for val in domain[:]:
1180
+ prods = [assigned_prod * val * o for o in other_products]
1181
+ if all(p < target_value for p in prods):
1182
+ domain.hideValue(val)
1183
+ if not domain:
1184
+ return False
1185
+
1186
+ return True
1187
+
1188
+
1189
+ class MaxProdConstraint(Constraint):
1190
+ """Constraint enforcing that values of given variables create a product up to at most a given amount.
1191
+
1192
+ Example:
1193
+ >>> problem = Problem()
1194
+ >>> problem.addVariables(["a", "b"], [1, 2])
1195
+ >>> problem.addConstraint(MaxProdConstraint(2))
1196
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1197
+ [[('a', 1), ('b', 1)], [('a', 1), ('b', 2)], [('a', 2), ('b', 1)]]
1198
+ >>> problem = Problem()
1199
+ >>> problem.addVariables(["a", "b"], [-2, -1, 1])
1200
+ >>> problem.addConstraint(MaxProdConstraint(-1))
1201
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1202
+ [[('a', -2), ('b', 1)], [('a', -1), ('b', 1)], [('a', 1), ('b', -2)], [('a', 1), ('b', -1)]]
1203
+ """
1204
+
1205
+ def __init__(self, maxprod: Union[int, float]):
1206
+ """Instantiate a MaxProdConstraint.
1207
+
1208
+ Args:
1209
+ maxprod: Value to be considered as the maximum product
1210
+ """
1211
+ self._maxprod = maxprod
1212
+ self._variable_contains_lt1: list[bool] = list()
1213
+
1214
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
1215
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
1216
+
1217
+ # check if there are any values less than 1 in the associated variables
1218
+ self._variable_contains_lt1: list[bool] = list()
1219
+ variable_with_lt1 = None
1220
+ for variable in variables:
1221
+ contains_lt1 = any(value < 1 for value in domains[variable])
1222
+ self._variable_contains_lt1.append(contains_lt1)
1223
+ for variable, contains_lt1 in zip(variables, self._variable_contains_lt1):
1224
+ if contains_lt1 is True:
1225
+ if variable_with_lt1 is not None:
1226
+ # if more than one associated variables contain less than 1, we can't prune
1227
+ return
1228
+ variable_with_lt1 = variable
1229
+
1230
+ # prune the associated variables of values > maxprod
1231
+ maxprod = self._maxprod
1232
+ for variable in variables:
1233
+ if variable_with_lt1 is not None and variable_with_lt1 != variable:
1234
+ continue
1235
+ domain = domains[variable]
1236
+ for value in domain[:]:
1237
+ if value > maxprod:
1238
+ domain.remove(value)
1239
+ elif value == 0 and maxprod < 0:
1240
+ domain.remove(value)
1241
+
1242
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
1243
+ maxprod = self._maxprod
1244
+ prod = 1
1245
+ missing = False
1246
+ missing_lt1 = []
1247
+ # find out which variables contain values less than 1 if not preprocessed
1248
+ if len(self._variable_contains_lt1) != len(variables):
1249
+ for variable in variables:
1250
+ self._variable_contains_lt1.append(any(value < 1 for value in domains[variable]))
1251
+ for variable, contains_lt1 in zip(variables, self._variable_contains_lt1):
1252
+ if variable in assignments:
1253
+ prod *= assignments[variable]
1254
+ else:
1255
+ missing = True
1256
+ if contains_lt1:
1257
+ missing_lt1.append(variable)
1258
+ if isinstance(prod, float):
1259
+ prod = round(prod, 10)
1260
+ if (not missing or len(missing_lt1) == 0) and prod > maxprod:
1261
+ return False
1262
+ if forwardcheck:
1263
+ for variable in variables:
1264
+ if variable not in assignments and (variable not in missing_lt1 or len(missing_lt1) == 1):
1265
+ domain = domains[variable]
1266
+ for value in domain[:]:
1267
+ if prod * value > maxprod:
1268
+ domain.hideValue(value)
1269
+ if not domain:
1270
+ return False
1271
+ return True
1272
+
1273
+
1274
+ class VariableMaxProdConstraint(Constraint):
1275
+ """Constraint enforcing that the product of variables is at most the value of another variable.
1276
+
1277
+ Example:
1278
+ >>> problem = Problem()
1279
+ >>> problem.addVariables(["a", "b", "c"], [-1, 2])
1280
+ >>> problem.addConstraint(VariableMaxProdConstraint('c', ['a', 'b']))
1281
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1282
+ [[('a', -1), ('b', -1), ('c', 2)], [('a', -1), ('b', 2), ('c', -1)], [('a', -1), ('b', 2), ('c', 2)], [('a', 2), ('b', -1), ('c', -1)], [('a', 2), ('b', -1), ('c', 2)]]
1283
+ >>> problem = Problem()
1284
+ >>> problem.addVariables(["a", "b"], [-2, -1, 1])
1285
+ >>> problem.addVariable('c', [-2, -3])
1286
+ >>> problem.addConstraint(VariableMaxProdConstraint('c', ['a', 'b']))
1287
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1288
+ [[('a', -2), ('b', 1), ('c', -2)], [('a', 1), ('b', -2), ('c', -2)]]
1289
+ """ # noqa: E501
1290
+
1291
+ def __init__(self, target_var: str, product_vars: Sequence[str]): # noqa: D107
1292
+ self.target_var = target_var
1293
+ self.product_vars = product_vars
1294
+
1295
+ def _get_product_bounds(self, domain_dict, exclude_var=None):
1296
+ bounds = []
1297
+ for var in self.product_vars:
1298
+ if var == exclude_var:
1299
+ continue
1300
+ dom = domain_dict[var]
1301
+ if not dom:
1302
+ continue
1303
+ bounds.append((min(dom), max(dom)))
1304
+
1305
+ if not bounds:
1306
+ return 1, 1
1307
+
1308
+ # Try all corner combinations
1309
+ candidates = [p for p in product(*[(lo, hi) for lo, hi in bounds])]
1310
+ products = [self._safe_product(p) for p in candidates]
1311
+ return min(products), max(products)
1312
+
1313
+ def _safe_product(self, values):
1314
+ prod = 1
1315
+ for v in values:
1316
+ prod *= v
1317
+ return prod
1318
+
1319
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
1320
+ Constraint.preProcess(self, variables, domains, constraints, vconstraints)
1321
+ target_dom = domains[self.target_var]
1322
+ t_max = max(target_dom)
1323
+
1324
+ for var in self.product_vars:
1325
+ min_others, max_others = self._get_product_bounds(domains, exclude_var=var)
1326
+ dom = domains[var]
1327
+ for val in dom[:]:
1328
+ possible_prods = [val * min_others, val * max_others]
1329
+ if min(possible_prods) > t_max:
1330
+ dom.remove(val)
1331
+
1332
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
1333
+ if self.target_var not in assignments:
1334
+ return True # Can't evaluate yet
1335
+
1336
+ target_value = assignments[self.target_var]
1337
+ assigned_prod = 1
1338
+ unassigned = []
1339
+
1340
+ for var in self.product_vars:
1341
+ if var in assignments:
1342
+ assigned_prod *= assignments[var]
1343
+ else:
1344
+ unassigned.append(var)
1345
+
1346
+ if not unassigned:
1347
+ return assigned_prod <= target_value
1348
+
1349
+ # Estimate max possible value of full product
1350
+ domain_bounds = [(min(domains[v]), max(domains[v])) for v in unassigned]
1351
+ candidates = [self._safe_product(p) for p in product(*[(lo, hi) for lo, hi in domain_bounds])]
1352
+ possible_prods = [assigned_prod * c for c in candidates]
1353
+ if min(possible_prods) > target_value:
1354
+ return False
1355
+
1356
+ if forwardcheck:
1357
+ for var in unassigned:
1358
+ other_unassigned = [v for v in unassigned if v != var]
1359
+ if other_unassigned:
1360
+ bounds = [(min(domains[v]), max(domains[v])) for v in other_unassigned]
1361
+ other_products = [self._safe_product(p) for p in product(*[(lo, hi) for lo, hi in bounds])]
1362
+ else:
1363
+ other_products = [1]
1364
+
1365
+ domain = domains[var]
1366
+ for val in domain[:]:
1367
+ prods = [assigned_prod * val * o for o in other_products]
1368
+ if all(p > target_value for p in prods):
1369
+ domain.hideValue(val)
1370
+ if not domain:
1371
+ return False
1372
+
1373
+ return True
1374
+
1375
+
1376
+ class InSetConstraint(Constraint):
1377
+ """Constraint enforcing that values of given variables are present in the given set.
1378
+
1379
+ Example:
1380
+ >>> problem = Problem()
1381
+ >>> problem.addVariables(["a", "b"], [1, 2])
1382
+ >>> problem.addConstraint(InSetConstraint([1]))
1383
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1384
+ [[('a', 1), ('b', 1)]]
1385
+ """
1386
+
1387
+ def __init__(self, set):
1388
+ """Initialization method.
1389
+
1390
+ Args:
1391
+ set (set): Set of allowed values
1392
+ """
1393
+ self._set = set
1394
+
1395
+ def __call__(self, variables, domains, assignments, forwardcheck=False): # noqa: D102
1396
+ # preProcess() will remove it.
1397
+ raise RuntimeError("Can't happen")
1398
+
1399
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
1400
+ set = self._set
1401
+ for variable in variables:
1402
+ domain = domains[variable]
1403
+ for value in domain[:]:
1404
+ if value not in set:
1405
+ domain.remove(value)
1406
+ vconstraints[variable].remove((self, variables))
1407
+ constraints.remove((self, variables))
1408
+
1409
+
1410
+ class NotInSetConstraint(Constraint):
1411
+ """Constraint enforcing that values of given variables are not present in the given set.
1412
+
1413
+ Example:
1414
+ >>> problem = Problem()
1415
+ >>> problem.addVariables(["a", "b"], [1, 2])
1416
+ >>> problem.addConstraint(NotInSetConstraint([1]))
1417
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1418
+ [[('a', 2), ('b', 2)]]
1419
+ """
1420
+
1421
+ def __init__(self, set):
1422
+ """Initialization method.
1423
+
1424
+ Args:
1425
+ set (set): Set of disallowed values
1426
+ """
1427
+ self._set = set
1428
+
1429
+ def __call__(self, variables, domains, assignments, forwardcheck=False): # noqa: D102
1430
+ # preProcess() will remove it.
1431
+ raise RuntimeError("Can't happen")
1432
+
1433
+ def preProcess(self, variables: Sequence, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
1434
+ set = self._set
1435
+ for variable in variables:
1436
+ domain = domains[variable]
1437
+ for value in domain[:]:
1438
+ if value in set:
1439
+ domain.remove(value)
1440
+ vconstraints[variable].remove((self, variables))
1441
+ constraints.remove((self, variables))
1442
+
1443
+
1444
+ class SomeInSetConstraint(Constraint):
1445
+ """Constraint enforcing that at least some of the values of given variables must be present in a given set.
1446
+
1447
+ Example:
1448
+ >>> problem = Problem()
1449
+ >>> problem.addVariables(["a", "b"], [1, 2])
1450
+ >>> problem.addConstraint(SomeInSetConstraint([1]))
1451
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1452
+ [[('a', 1), ('b', 1)], [('a', 1), ('b', 2)], [('a', 2), ('b', 1)]]
1453
+ """
1454
+
1455
+ def __init__(self, set, n=1, exact=False):
1456
+ """Initialization method.
1457
+
1458
+ Args:
1459
+ set (set): Set of values to be checked
1460
+ n (int): Minimum number of assigned values that should be
1461
+ present in set (default is 1)
1462
+ exact (bool): Whether the number of assigned values which
1463
+ are present in set must be exactly `n`
1464
+ """
1465
+ self._set = set
1466
+ self._n = n
1467
+ self._exact = exact
1468
+
1469
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
1470
+ set = self._set
1471
+ missing = 0
1472
+ found = 0
1473
+ for variable in variables:
1474
+ if variable in assignments:
1475
+ found += assignments[variable] in set
1476
+ else:
1477
+ missing += 1
1478
+ if missing:
1479
+ if self._exact:
1480
+ if not (found <= self._n <= missing + found):
1481
+ return False
1482
+ else:
1483
+ if self._n > missing + found:
1484
+ return False
1485
+ if forwardcheck and self._n - found == missing:
1486
+ # All unassigned variables must be assigned to
1487
+ # values in the set.
1488
+ for variable in variables:
1489
+ if variable not in assignments:
1490
+ domain = domains[variable]
1491
+ for value in domain[:]:
1492
+ if value not in set:
1493
+ domain.hideValue(value)
1494
+ if not domain:
1495
+ return False
1496
+ else:
1497
+ if self._exact:
1498
+ if found != self._n:
1499
+ return False
1500
+ else:
1501
+ if found < self._n:
1502
+ return False
1503
+ return True
1504
+
1505
+
1506
+ class SomeNotInSetConstraint(Constraint):
1507
+ """Constraint enforcing that at least some of the values of given variables must not be present in a given set.
1508
+
1509
+ Example:
1510
+ >>> problem = Problem()
1511
+ >>> problem.addVariables(["a", "b"], [1, 2])
1512
+ >>> problem.addConstraint(SomeNotInSetConstraint([1]))
1513
+ >>> sorted(sorted(x.items()) for x in problem.getSolutions())
1514
+ [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)], [('a', 2), ('b', 2)]]
1515
+ """
1516
+
1517
+ def __init__(self, set, n=1, exact=False):
1518
+ """Initialization method.
1519
+
1520
+ Args:
1521
+ set (set): Set of values to be checked
1522
+ n (int): Minimum number of assigned values that should not
1523
+ be present in set (default is 1)
1524
+ exact (bool): Whether the number of assigned values which
1525
+ are not present in set must be exactly `n`
1526
+ """
1527
+ self._set = set
1528
+ self._n = n
1529
+ self._exact = exact
1530
+
1531
+ def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102
1532
+ set = self._set
1533
+ missing = 0
1534
+ found = 0
1535
+ for variable in variables:
1536
+ if variable in assignments:
1537
+ found += assignments[variable] not in set
1538
+ else:
1539
+ missing += 1
1540
+ if missing:
1541
+ if self._exact:
1542
+ if not (found <= self._n <= missing + found):
1543
+ return False
1544
+ else:
1545
+ if self._n > missing + found:
1546
+ return False
1547
+ if forwardcheck and self._n - found == missing:
1548
+ # All unassigned variables must be assigned to
1549
+ # values not in the set.
1550
+ for variable in variables:
1551
+ if variable not in assignments:
1552
+ domain = domains[variable]
1553
+ for value in domain[:]:
1554
+ if value in set:
1555
+ domain.hideValue(value)
1556
+ if not domain:
1557
+ return False
1558
+ else:
1559
+ if self._exact:
1560
+ if found != self._n:
1561
+ return False
1562
+ else:
1563
+ if found < self._n:
1564
+ return False
1565
+ return True
1566
+
1567
+
1568
+ # Utility functions
1569
+
1570
+ def sum_other_vars(variables: Sequence, variable, values: dict):
1571
+ """Calculate the sum of the given values of all other variables."""
1572
+ return sum(values[v] for v in variables if v != variable)