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.
constraint/solvers.py ADDED
@@ -0,0 +1,788 @@
1
+ """Module containing the code for the problem solvers."""
2
+
3
+ import random
4
+ from types import FunctionType
5
+ from constraint.domain import Domain
6
+ from constraint.constraints import Constraint, FunctionConstraint, CompilableFunctionConstraint
7
+ from collections.abc import Hashable
8
+
9
+ # for parallel solver
10
+ from concurrent.futures import ProcessPoolExecutor, ThreadPoolExecutor
11
+
12
+
13
+ def getArcs(domains: dict, constraints: list[tuple]) -> dict:
14
+ """Return a dictionary mapping pairs (arcs) of constrained variables.
15
+
16
+ @attention: Currently unused.
17
+ """
18
+ arcs = {}
19
+ for x in constraints:
20
+ constraint, variables = x
21
+ if len(variables) == 2:
22
+ variable1, variable2 = variables
23
+ arcs.setdefault(variable1, {}).setdefault(variable2, []).append(x)
24
+ arcs.setdefault(variable2, {}).setdefault(variable1, []).append(x)
25
+ return arcs
26
+
27
+
28
+ def doArc8(arcs: dict, domains: dict, assignments: dict) -> bool:
29
+ """Perform the ARC-8 arc checking algorithm and prune domains.
30
+
31
+ @attention: Currently unused.
32
+ """
33
+ check = dict.fromkeys(domains, True)
34
+ while check:
35
+ variable, _ = check.popitem()
36
+ if variable not in arcs or variable in assignments:
37
+ continue
38
+ domain = domains[variable]
39
+ arcsvariable = arcs[variable]
40
+ for othervariable in arcsvariable:
41
+ arcconstraints = arcsvariable[othervariable]
42
+ if othervariable in assignments:
43
+ otherdomain = [assignments[othervariable]]
44
+ else:
45
+ otherdomain = domains[othervariable]
46
+ if domain:
47
+ # changed = False
48
+ for value in domain[:]:
49
+ assignments[variable] = value
50
+ if otherdomain:
51
+ for othervalue in otherdomain:
52
+ assignments[othervariable] = othervalue
53
+ for constraint, variables in arcconstraints:
54
+ if not constraint(variables, domains, assignments, True):
55
+ break
56
+ else:
57
+ # All constraints passed. Value is safe.
58
+ break
59
+ else:
60
+ # All othervalues failed. Kill value.
61
+ domain.hideValue(value)
62
+ # changed = True
63
+ del assignments[othervariable]
64
+ del assignments[variable]
65
+ # if changed:
66
+ # check.update(dict.fromkeys(arcsvariable))
67
+ if not domain:
68
+ return False
69
+ return True
70
+
71
+
72
+ class Solver:
73
+ """Abstract base class for solvers."""
74
+
75
+ requires_pickling = False
76
+
77
+ def getSolution(self, domains: dict, constraints: list[tuple], vconstraints: dict):
78
+ """Return one solution for the given problem.
79
+
80
+ Args:
81
+ domains (dict): Dictionary mapping variables to their domains
82
+ constraints (list): List of pairs of (constraint, variables)
83
+ vconstraints (dict): Dictionary mapping variables to a list
84
+ of constraints affecting the given variables.
85
+ """
86
+ msg = f"{self.__class__.__name__} is an abstract class"
87
+ raise NotImplementedError(msg)
88
+
89
+ def getSolutions(self, domains: dict, constraints: list[tuple], vconstraints: dict):
90
+ """Return all solutions for the given problem.
91
+
92
+ Args:
93
+ domains (dict): Dictionary mapping variables to domains
94
+ constraints (list): List of pairs of (constraint, variables)
95
+ vconstraints (dict): Dictionary mapping variables to a list
96
+ of constraints affecting the given variables.
97
+ """
98
+ msg = f"{self.__class__.__name__} provides only a single solution"
99
+ raise NotImplementedError(msg)
100
+
101
+ def getSolutionIter(self, domains: dict, constraints: list[tuple], vconstraints: dict):
102
+ """Return an iterator for the solutions of the given problem.
103
+
104
+ Args:
105
+ domains (dict): Dictionary mapping variables to domains
106
+ constraints (list): List of pairs of (constraint, variables)
107
+ vconstraints (dict): Dictionary mapping variables to a list
108
+ of constraints affecting the given variables.
109
+ """
110
+ msg = f"{self.__class__.__name__} doesn't provide iteration"
111
+ raise NotImplementedError(msg)
112
+
113
+
114
+ class BacktrackingSolver(Solver):
115
+ """Problem solver with backtracking capabilities.
116
+
117
+ Examples:
118
+ >>> result = [[('a', 1), ('b', 2)],
119
+ ... [('a', 1), ('b', 3)],
120
+ ... [('a', 2), ('b', 3)]]
121
+
122
+ >>> problem = Problem(BacktrackingSolver())
123
+ >>> problem.addVariables(["a", "b"], [1, 2, 3])
124
+ >>> problem.addConstraint(lambda a, b: b > a, ["a", "b"])
125
+
126
+ >>> solution = problem.getSolution()
127
+ >>> sorted(solution.items()) in result
128
+ True
129
+
130
+ >>> for solution in problem.getSolutionIter():
131
+ ... sorted(solution.items()) in result
132
+ True
133
+ True
134
+ True
135
+
136
+ >>> for solution in problem.getSolutions():
137
+ ... sorted(solution.items()) in result
138
+ True
139
+ True
140
+ True
141
+ """
142
+
143
+ def __init__(self, forwardcheck=True):
144
+ """Initialization method.
145
+
146
+ Args:
147
+ forwardcheck (bool): If false forward checking will not be
148
+ requested to constraints while looking for solutions
149
+ (default is true)
150
+ """
151
+ self._forwardcheck = forwardcheck
152
+
153
+ def getSolutionIter(self, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
154
+ forwardcheck = self._forwardcheck
155
+ assignments = {}
156
+
157
+ queue = []
158
+
159
+ while True:
160
+ # Mix the Degree and Minimum Remaing Values (MRV) heuristics
161
+ lst = [(-len(vconstraints[variable]), len(domains[variable]), variable) for variable in domains]
162
+ lst.sort(key=lambda x: (x[0], x[1]))
163
+ for item in lst:
164
+ if item[-1] not in assignments:
165
+ # Found unassigned variable
166
+ variable = item[-1]
167
+ values = domains[variable][:]
168
+ if forwardcheck:
169
+ pushdomains = [domains[x] for x in domains if x not in assignments and x != variable]
170
+ else:
171
+ pushdomains = None
172
+ break
173
+ else:
174
+ # No unassigned variables. We've got a solution. Go back
175
+ # to last variable, if there's one.
176
+ yield assignments.copy()
177
+ if not queue:
178
+ return
179
+ variable, values, pushdomains = queue.pop()
180
+ if pushdomains:
181
+ for domain in pushdomains:
182
+ domain.popState()
183
+
184
+ while True:
185
+ # We have a variable. Do we have any values left?
186
+ if not values:
187
+ # No. Go back to last variable, if there's one.
188
+ del assignments[variable]
189
+ while queue:
190
+ variable, values, pushdomains = queue.pop()
191
+ if pushdomains:
192
+ for domain in pushdomains:
193
+ domain.popState()
194
+ if values:
195
+ break
196
+ del assignments[variable]
197
+ else:
198
+ return
199
+
200
+ # Got a value. Check it.
201
+ assignments[variable] = values.pop()
202
+
203
+ if pushdomains:
204
+ for domain in pushdomains:
205
+ domain.pushState()
206
+
207
+ for constraint, variables in vconstraints[variable]:
208
+ if not constraint(variables, domains, assignments, pushdomains):
209
+ # Value is not good.
210
+ break
211
+ else:
212
+ break
213
+
214
+ if pushdomains:
215
+ for domain in pushdomains:
216
+ domain.popState()
217
+
218
+ # Push state before looking for next variable.
219
+ queue.append((variable, values, pushdomains))
220
+
221
+ raise RuntimeError("Can't happen")
222
+
223
+ def getSolution(self, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
224
+ iter = self.getSolutionIter(domains, constraints, vconstraints)
225
+ try:
226
+ return next(iter)
227
+ except StopIteration:
228
+ return None
229
+
230
+ def getSolutions(self, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
231
+ return list(self.getSolutionIter(domains, constraints, vconstraints))
232
+
233
+
234
+ class OptimizedBacktrackingSolver(Solver):
235
+ """Problem solver with backtracking capabilities, implementing several optimizations for increased performance.
236
+
237
+ Optimizations are especially in obtaining all solutions.
238
+ View https://github.com/python-constraint/python-constraint/pull/76 for more details.
239
+
240
+ Examples:
241
+ >>> result = [[('a', 1), ('b', 2)],
242
+ ... [('a', 1), ('b', 3)],
243
+ ... [('a', 2), ('b', 3)]]
244
+
245
+ >>> problem = Problem(OptimizedBacktrackingSolver())
246
+ >>> problem.addVariables(["a", "b"], [1, 2, 3])
247
+ >>> problem.addConstraint(lambda a, b: b > a, ["a", "b"])
248
+
249
+ >>> solution = problem.getSolution()
250
+ >>> sorted(solution.items()) in result
251
+ True
252
+
253
+ >>> for solution in problem.getSolutionIter():
254
+ ... sorted(solution.items()) in result
255
+ True
256
+ True
257
+ True
258
+
259
+ >>> for solution in problem.getSolutions():
260
+ ... sorted(solution.items()) in result
261
+ True
262
+ True
263
+ True
264
+ """
265
+
266
+ def __init__(self, forwardcheck=True):
267
+ """Initialization method.
268
+
269
+ Args:
270
+ forwardcheck (bool): If false forward checking will not be
271
+ requested to constraints while looking for solutions
272
+ (default is true)
273
+ """
274
+ self._forwardcheck = forwardcheck
275
+
276
+ def getSolutionIter(self, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
277
+ forwardcheck = self._forwardcheck
278
+ assignments = {}
279
+ sorted_variables = self.getSortedVariables(domains, vconstraints)
280
+
281
+ queue = []
282
+
283
+ while True:
284
+ # Mix the Degree and Minimum Remaing Values (MRV) heuristics
285
+ for variable in sorted_variables:
286
+ if variable not in assignments:
287
+ # Found unassigned variable
288
+ values = domains[variable][:]
289
+ if forwardcheck:
290
+ pushdomains = [domains[x] for x in domains if x not in assignments and x != variable]
291
+ else:
292
+ pushdomains = None
293
+ break
294
+ else:
295
+ # No unassigned variables. We've got a solution. Go back
296
+ # to last variable, if there's one.
297
+ yield assignments.copy()
298
+ if not queue:
299
+ return
300
+ variable, values, pushdomains = queue.pop()
301
+ if pushdomains:
302
+ for domain in pushdomains:
303
+ domain.popState()
304
+
305
+ while True:
306
+ # We have a variable. Do we have any values left?
307
+ if not values:
308
+ # No. Go back to last variable, if there's one.
309
+ del assignments[variable]
310
+ while queue:
311
+ variable, values, pushdomains = queue.pop()
312
+ if pushdomains:
313
+ for domain in pushdomains:
314
+ domain.popState()
315
+ if values:
316
+ break
317
+ del assignments[variable]
318
+ else:
319
+ return
320
+
321
+ # Got a value. Check it.
322
+ assignments[variable] = values.pop()
323
+
324
+ if pushdomains:
325
+ for domain in pushdomains:
326
+ domain.pushState()
327
+
328
+ for constraint, variables in vconstraints[variable]:
329
+ if not constraint(variables, domains, assignments, pushdomains):
330
+ # Value is not good.
331
+ break
332
+ else:
333
+ break
334
+
335
+ if pushdomains:
336
+ for domain in pushdomains:
337
+ domain.popState()
338
+
339
+ # Push state before looking for next variable.
340
+ queue.append((variable, values, pushdomains))
341
+
342
+ raise RuntimeError("Can't happen")
343
+
344
+ def getSolutionsList(self, domains: dict[Hashable, Domain], vconstraints: dict[Hashable, list[tuple[Constraint, Hashable]]]) -> list[dict[Hashable, any]]: # noqa: D102, E501
345
+ """Optimized all-solutions finder that skips forwardchecking and returns the solutions in a list.
346
+
347
+ Args:
348
+ domains: Dictionary mapping variables to domains
349
+ vconstraints: Dictionary mapping variables to a list of constraints affecting the given variables.
350
+
351
+ Returns:
352
+ the list of solutions as a dictionary.
353
+ """
354
+ # Does not do forwardcheck for simplicity
355
+
356
+ assignments: dict = {}
357
+ queue: list[tuple] = []
358
+ solutions: list[dict] = list()
359
+ sorted_variables = self.getSortedVariables(domains, vconstraints)
360
+
361
+ while True:
362
+ # Mix the Degree and Minimum Remaing Values (MRV) heuristics
363
+ for variable in sorted_variables:
364
+ if variable not in assignments:
365
+ # Found unassigned variable
366
+ values = domains[variable][:]
367
+ break
368
+ else:
369
+ # No unassigned variables. We've got a solution. Go back
370
+ # to last variable, if there's one.
371
+ solutions.append(assignments.copy())
372
+ if not queue:
373
+ return solutions
374
+ variable, values = queue.pop()
375
+
376
+ while True:
377
+ # We have a variable. Do we have any values left?
378
+ if not values:
379
+ # No. Go back to last variable, if there's one.
380
+ del assignments[variable]
381
+ while queue:
382
+ variable, values = queue.pop()
383
+ if values:
384
+ break
385
+ del assignments[variable]
386
+ else:
387
+ return solutions
388
+
389
+ # Got a value. Check it.
390
+ assignments[variable] = values.pop()
391
+ for constraint, variables in vconstraints[variable]:
392
+ if not constraint(variables, domains, assignments, None):
393
+ # Value is not good.
394
+ break
395
+ else:
396
+ break
397
+
398
+ # Push state before looking for next variable.
399
+ queue.append((variable, values))
400
+
401
+ def getSolutions(self, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
402
+ if self._forwardcheck:
403
+ return list(self.getSolutionIter(domains, constraints, vconstraints))
404
+ return self.getSolutionsList(domains, vconstraints)
405
+
406
+ def getSolution(self, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
407
+ iter = self.getSolutionIter(domains, constraints, vconstraints)
408
+ try:
409
+ return next(iter)
410
+ except StopIteration:
411
+ return None
412
+
413
+ def getSortedVariables(self, domains: dict, vconstraints: dict) -> list:
414
+ """Sorts the list of variables on number of vconstraints to find unassigned variables quicker.
415
+
416
+ Args:
417
+ domains: Dictionary mapping variables to their domains
418
+ vconstraints: Dictionary mapping variables to a list
419
+ of constraints affecting the given variables.
420
+
421
+ Returns:
422
+ the list of variables, sorted from highest number of vconstraints to lowest.
423
+ """
424
+ lst = [(-len(vconstraints[variable]), len(domains[variable]), variable) for variable in domains]
425
+ lst.sort(key=lambda x: (x[0], x[1]))
426
+ return [c for _, _, c in lst]
427
+
428
+ class RecursiveBacktrackingSolver(Solver):
429
+ """Recursive problem solver with backtracking capabilities.
430
+
431
+ Examples:
432
+ >>> result = [[('a', 1), ('b', 2)],
433
+ ... [('a', 1), ('b', 3)],
434
+ ... [('a', 2), ('b', 3)]]
435
+
436
+ >>> problem = Problem(RecursiveBacktrackingSolver())
437
+ >>> problem.addVariables(["a", "b"], [1, 2, 3])
438
+ >>> problem.addConstraint(lambda a, b: b > a, ["a", "b"])
439
+
440
+ >>> solution = problem.getSolution()
441
+ >>> sorted(solution.items()) in result
442
+ True
443
+
444
+ >>> for solution in problem.getSolutions():
445
+ ... sorted(solution.items()) in result
446
+ True
447
+ True
448
+ True
449
+
450
+ >>> problem.getSolutionIter()
451
+ Traceback (most recent call last):
452
+ ...
453
+ NotImplementedError: RecursiveBacktrackingSolver doesn't provide iteration
454
+ """
455
+
456
+ def __init__(self, forwardcheck=True):
457
+ """Initialization method.
458
+
459
+ Args:
460
+ forwardcheck (bool): If false forward checking will not be
461
+ requested to constraints while looking for solutions
462
+ (default is true)
463
+ """
464
+ self._forwardcheck = forwardcheck
465
+
466
+ def recursiveBacktracking(self, solutions, domains, vconstraints, assignments, single):
467
+ """Mix the Degree and Minimum Remaing Values (MRV) heuristics.
468
+
469
+ Args:
470
+ solutions: _description_
471
+ domains: _description_
472
+ vconstraints: _description_
473
+ assignments: _description_
474
+ single: _description_
475
+
476
+ Returns:
477
+ _description_
478
+ """
479
+ lst = [(-len(vconstraints[variable]), len(domains[variable]), variable) for variable in domains]
480
+ lst.sort(key=lambda x: (x[0], x[1]))
481
+ for item in lst:
482
+ if item[-1] not in assignments:
483
+ # Found an unassigned variable. Let's go.
484
+ break
485
+ else:
486
+ # No unassigned variables. We've got a solution.
487
+ solutions.append(assignments.copy())
488
+ return solutions
489
+
490
+ variable = item[-1]
491
+ assignments[variable] = None
492
+
493
+ forwardcheck = self._forwardcheck
494
+ if forwardcheck:
495
+ pushdomains = [domains[x] for x in domains if x not in assignments]
496
+ else:
497
+ pushdomains = None
498
+
499
+ for value in domains[variable]:
500
+ assignments[variable] = value
501
+ if pushdomains:
502
+ for domain in pushdomains:
503
+ domain.pushState()
504
+ for constraint, variables in vconstraints[variable]:
505
+ if not constraint(variables, domains, assignments, pushdomains):
506
+ # Value is not good.
507
+ break
508
+ else:
509
+ # Value is good. Recurse and get next variable.
510
+ self.recursiveBacktracking(solutions, domains, vconstraints, assignments, single)
511
+ if solutions and single:
512
+ return solutions
513
+ if pushdomains:
514
+ for domain in pushdomains:
515
+ domain.popState()
516
+ del assignments[variable]
517
+ return solutions
518
+
519
+ def getSolution(self, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
520
+ solutions = self.recursiveBacktracking([], domains, vconstraints, {}, True)
521
+ return solutions and solutions[0] or None
522
+
523
+ def getSolutions(self, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
524
+ return self.recursiveBacktracking([], domains, vconstraints, {}, False)
525
+
526
+
527
+ class MinConflictsSolver(Solver):
528
+ """Problem solver based on the minimum conflicts theory.
529
+
530
+ Examples:
531
+ >>> result = [[('a', 1), ('b', 2)],
532
+ ... [('a', 1), ('b', 3)],
533
+ ... [('a', 2), ('b', 3)]]
534
+
535
+ >>> problem = Problem(MinConflictsSolver())
536
+ >>> problem.addVariables(["a", "b"], [1, 2, 3])
537
+ >>> problem.addConstraint(lambda a, b: b > a, ["a", "b"])
538
+
539
+ >>> solution = problem.getSolution()
540
+ >>> sorted(solution.items()) in result
541
+ True
542
+
543
+ >>> problem.getSolutions()
544
+ Traceback (most recent call last):
545
+ ...
546
+ NotImplementedError: MinConflictsSolver provides only a single solution
547
+
548
+ >>> problem.getSolutionIter()
549
+ Traceback (most recent call last):
550
+ ...
551
+ NotImplementedError: MinConflictsSolver doesn't provide iteration
552
+ """
553
+
554
+ def __init__(self, steps=1000, rand=None):
555
+ """Initialization method.
556
+
557
+ Args:
558
+ steps (int): Maximum number of steps to perform before
559
+ giving up when looking for a solution (default is 1000)
560
+ rand (Random): Optional random.Random instance to use for
561
+ repeatability.
562
+ """
563
+ self._steps = steps
564
+ self._rand = rand
565
+
566
+ def getSolution(self, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
567
+ choice = self._rand.choice if self._rand is not None else random.choice
568
+ shuffle = self._rand.shuffle if self._rand is not None else random.shuffle
569
+ assignments = {}
570
+ # Initial assignment
571
+ for variable in domains:
572
+ assignments[variable] = choice(domains[variable])
573
+ for _ in range(self._steps):
574
+ conflicted = False
575
+ lst = list(domains.keys())
576
+ shuffle(lst)
577
+ for variable in lst:
578
+ # Check if variable is not in conflict
579
+ for constraint, variables in vconstraints[variable]:
580
+ if not constraint(variables, domains, assignments):
581
+ break
582
+ else:
583
+ continue
584
+ # Variable has conflicts. Find values with less conflicts.
585
+ mincount = len(vconstraints[variable])
586
+ minvalues = []
587
+ for value in domains[variable]:
588
+ assignments[variable] = value
589
+ count = 0
590
+ for constraint, variables in vconstraints[variable]:
591
+ if not constraint(variables, domains, assignments):
592
+ count += 1
593
+ if count == mincount:
594
+ minvalues.append(value)
595
+ elif count < mincount:
596
+ mincount = count
597
+ del minvalues[:]
598
+ minvalues.append(value)
599
+ # Pick a random one from these values.
600
+ assignments[variable] = choice(minvalues)
601
+ conflicted = True
602
+ if not conflicted:
603
+ return assignments
604
+ return None
605
+
606
+
607
+ class ParallelSolver(Solver):
608
+ """Problem solver that executes all-solution solve in parallel (ProcessPool or ThreadPool mode).
609
+
610
+ Sorts the domains on size, creating jobs for each value in the domain with the most variables.
611
+ Each leaf job is solved locally with either optimized backtracking or recursion.
612
+ Whether this is actually faster than non-parallel solving depends on your problem, and hardware and software environment.
613
+
614
+ Uses ThreadPool by default. Instantiate with process_mode=True to use ProcessPool.
615
+ In ProcessPool mode, the jobs do not share memory.
616
+ In ProcessPool mode, precompiled FunctionConstraints are not allowed due to pickling, use string constraints instead.
617
+
618
+ Examples:
619
+ >>> result = [[('a', 1), ('b', 2)],
620
+ ... [('a', 1), ('b', 3)],
621
+ ... [('a', 2), ('b', 3)]]
622
+
623
+ >>> problem = Problem(ParallelSolver())
624
+ >>> problem.addVariables(["a", "b"], [1, 2, 3])
625
+ >>> problem.addConstraint("b > a", ["a", "b"])
626
+
627
+ >>> for solution in problem.getSolutions():
628
+ ... sorted(solution.items()) in result
629
+ True
630
+ True
631
+ True
632
+
633
+ >>> problem.getSolution()
634
+ Traceback (most recent call last):
635
+ ...
636
+ NotImplementedError: ParallelSolver only provides all solutions
637
+
638
+ >>> problem.getSolutionIter()
639
+ Traceback (most recent call last):
640
+ ...
641
+ NotImplementedError: ParallelSolver doesn't provide iteration
642
+ """ # noqa E501
643
+
644
+ def __init__(self, process_mode=False):
645
+ """Initialization method. Set `process_mode` to True for using ProcessPool, otherwise uses ThreadPool."""
646
+ super().__init__()
647
+ self._process_mode = process_mode
648
+ self.requires_pickling = process_mode
649
+
650
+ def getSolution(self, domains: dict, constraints: list[tuple], vconstraints: dict):
651
+ """Return one solution for the given problem.
652
+
653
+ Args:
654
+ domains (dict): Dictionary mapping variables to their domains
655
+ constraints (list): List of pairs of (constraint, variables)
656
+ vconstraints (dict): Dictionary mapping variables to a list
657
+ of constraints affecting the given variables.
658
+ """
659
+ msg = f"{self.__class__.__name__} only provides all solutions"
660
+ raise NotImplementedError(msg)
661
+
662
+ def getSolutionsList(self, domains: dict[Hashable, Domain], vconstraints: dict[Hashable, list[tuple[Constraint, Hashable]]]) -> list[dict[Hashable, any]]: # noqa: D102, E501
663
+ """Parallelized all-solutions finder using ProcessPoolExecutor for work-stealing."""
664
+ # Precompute constraints lookup per variable
665
+ constraint_lookup: dict[Hashable, list[tuple[Constraint, Hashable]]] = {var: vconstraints.get(var, []) for var in domains} # noqa: E501
666
+
667
+ # Sort variables by domain size (heuristic)
668
+ sorted_vars: list[Hashable] = sorted(domains.keys(), key=lambda v: len(domains[v]))
669
+
670
+ # Split parallel and sequential parts
671
+ first_var = sorted_vars[0]
672
+ remaining_vars = sorted_vars[1:]
673
+
674
+ # Create the parallel function arguments and solutions lists
675
+ args = ((self.requires_pickling, domains, constraint_lookup, first_var, val, remaining_vars.copy()) for val in domains[first_var]) # noqa: E501
676
+ solutions: list[dict[Hashable, any]] = []
677
+
678
+ # execute in parallel
679
+ parallel_pool = ProcessPoolExecutor if self._process_mode else ThreadPoolExecutor
680
+ with parallel_pool() as executor:
681
+ # results = map(parallel_worker, args) # sequential
682
+ results = executor.map(parallel_worker, args, chunksize=1) # parallel
683
+ for result in results:
684
+ solutions.extend(result)
685
+
686
+ return solutions
687
+
688
+ def getSolutions(self, domains: dict, constraints: list[tuple], vconstraints: dict): # noqa: D102
689
+ return self.getSolutionsList(domains, vconstraints)
690
+
691
+ ### Helper functions for parallel solver
692
+
693
+ def is_valid(assignment: dict[Hashable, any], constraints_lookup: list[tuple[Constraint, Hashable]], domains: dict[Hashable, Domain]) -> bool: # noqa E501
694
+ """Check if all constraints are satisfied given the current assignment."""
695
+ return all(
696
+ constraint(vars_involved, domains, assignment, None)
697
+ for constraint, vars_involved in constraints_lookup
698
+ if all(v in assignment for v in vars_involved)
699
+ )
700
+
701
+ def compile_to_function(constraint: CompilableFunctionConstraint) -> FunctionConstraint:
702
+ """Compile a CompilableFunctionConstraint to a function, wrapped by a FunctionConstraint."""
703
+ func_string = constraint._func
704
+ code_object = compile(func_string, "<string>", "exec")
705
+ func = FunctionType(code_object.co_consts[0], globals())
706
+ return FunctionConstraint(func)
707
+
708
+ def sequential_recursive_backtrack(assignment: dict[Hashable, any], unassigned_vars: list[Hashable], domains: dict[Hashable, Domain], constraint_lookup: dict[Hashable, list[tuple[Constraint, Hashable]]]) -> list[dict[Hashable, any]]: # noqa E501
709
+ """Sequential recursive backtracking function for subproblems."""
710
+ if not unassigned_vars:
711
+ return [assignment.copy()]
712
+
713
+ var = unassigned_vars[-1]
714
+ remaining_vars = unassigned_vars[:-1]
715
+
716
+ solutions: list[dict[Hashable, any]] = []
717
+ for value in domains[var]:
718
+ assignment[var] = value
719
+ if is_valid(assignment, constraint_lookup[var], domains):
720
+ solutions.extend(sequential_recursive_backtrack(assignment, remaining_vars, domains, constraint_lookup))
721
+ del assignment[var]
722
+ return solutions
723
+
724
+ def sequential_optimized_backtrack(assignment: dict[Hashable, any], unassigned_vars: list[Hashable], domains: dict[Hashable, Domain], constraint_lookup: dict[Hashable, list[tuple[Constraint, Hashable]]]) -> list[dict[Hashable, any]]: # noqa E501
725
+ """Sequential optimized backtracking (as in OptimizedBacktrackingSolver) function for subproblems."""
726
+ # Does not do forwardcheck for simplicity
727
+
728
+ assignments = assignment
729
+ sorted_variables = unassigned_vars
730
+ queue: list[tuple] = []
731
+ solutions: list[dict] = list()
732
+
733
+ while True:
734
+ # Mix the Degree and Minimum Remaing Values (MRV) heuristics
735
+ for variable in sorted_variables:
736
+ if variable not in assignments:
737
+ # Found unassigned variable
738
+ values = domains[variable][:]
739
+ break
740
+ else:
741
+ # No unassigned variables. We've got a solution. Go back
742
+ # to last variable, if there's one.
743
+ solutions.append(assignments.copy())
744
+ if not queue:
745
+ return solutions
746
+ variable, values = queue.pop()
747
+
748
+ while True:
749
+ # We have a variable. Do we have any values left?
750
+ if not values:
751
+ # No. Go back to last variable, if there's one.
752
+ del assignments[variable]
753
+ while queue:
754
+ variable, values = queue.pop()
755
+ if values:
756
+ break
757
+ del assignments[variable]
758
+ else:
759
+ return solutions
760
+
761
+ # Got a value. Check it.
762
+ assignments[variable] = values.pop()
763
+ for constraint, variables in constraint_lookup[variable]:
764
+ if not constraint(variables, domains, assignments, None):
765
+ # Value is not good.
766
+ break
767
+ else:
768
+ break
769
+
770
+ # Push state before looking for next variable.
771
+ queue.append((variable, values))
772
+
773
+
774
+ def parallel_worker(args: tuple[bool, dict[Hashable, Domain], dict[Hashable, list[tuple[Constraint, Hashable]]], Hashable, any, list[Hashable]]) -> list[dict[Hashable, any]]: # noqa E501
775
+ """Worker function for parallel execution on first variable."""
776
+ process_mode, domains, constraint_lookup, first_var, first_value, remaining_vars = args
777
+ local_assignment = {first_var: first_value}
778
+
779
+ if process_mode:
780
+ # if there are any CompilableFunctionConstraint, they must be compiled locally first
781
+ for var, constraints in constraint_lookup.items():
782
+ constraint_lookup[var] = [tuple([compile_to_function(constraint) if isinstance(constraint, CompilableFunctionConstraint) else constraint, vals]) for constraint, vals in constraints] # noqa E501
783
+
784
+ # continue solving sequentially on this process
785
+ if is_valid(local_assignment, constraint_lookup[first_var], domains):
786
+ return sequential_optimized_backtrack(local_assignment, remaining_vars, domains, constraint_lookup)
787
+ return []
788
+