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