diff --git a/.coveragerc b/.coveragerc new file mode 100644 index 0000000..7d1785c --- /dev/null +++ b/.coveragerc @@ -0,0 +1,8 @@ +[run] +source = constraint +omit = *tests*, *examples* + +[report] +exclude_lines = + if __name__ == .__main__.: + pragma: no cover diff --git a/.github/workflows/build-test-python-package.yml b/.github/workflows/build-test-python-package.yml new file mode 100644 index 0000000..b0c2d91 --- /dev/null +++ b/.github/workflows/build-test-python-package.yml @@ -0,0 +1,40 @@ +# This workflow will install Python dependencies, run tests and lint for the supported Python versions. +# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-python. + +name: Build & Test + +on: + push: + branches: + - main + - release/* + pull_request: + branches: + - main + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + python-version: ["3.8", "3.9", "3.10", "3.11"] + + steps: + - uses: actions/checkout@v3 + - name: Set up Python ${{ matrix.python-version }} + uses: actions/setup-python@v4 + with: + python-version: ${{ matrix.python-version }} + - name: Install dependencies + run: | + python -m pip install --upgrade pip + pip install .[test] + - name: Lint with ruff + run: | + # stop the build if there are Python syntax errors or undefined names + ruff --format=github --select=E9,F63,F7,F82 --config=pyproject.toml . + # default set of ruff rules with GitHub Annotations + ruff --format=github --config=pyproject.toml . + - name: Test with pytest + run: | + pytest diff --git a/.gitignore b/.gitignore index b102207..24394c9 100644 --- a/.gitignore +++ b/.gitignore @@ -58,3 +58,10 @@ target/ # PyCharm / Intellij .idea/ + +### OS Specific ### + +# Mac +.DS_store +.AppleDouble +.LSOverride diff --git a/.travis.yml b/.travis.yml index cb8dd6f..8ba3542 100644 --- a/.travis.yml +++ b/.travis.yml @@ -1,23 +1,26 @@ language: python python: -- '2.7' -- '3.4' -- '3.5' -- '3.6' + - "3.4" + - "3.5" + - "3.6" + - "3.7" + - "3.8" + - "3.9" + - "3.10" install: -- pip install -qq flake8 -- pip install coveralls --quiet -- pip install . + - pip install -qq flake8 + - pip install coveralls --quiet + - pip install . script: -- nosetests -s -v --with-coverage --cover-package=constraint -- flake8 --ignore E501,W504 constraint examples tests + - nosetests -s -v --with-coverage --cover-package=constraint + - flake8 --ignore E501,W504 constraint examples tests after_success: -- coveralls + - coveralls deploy: - provider: pypi - user: "scls" - password: - secure: YP1I8vi04F2mhaexylEK4PoizOMKfPy6ixDGYYLg5WAvgNdHvBN39xFsd9WHavMGg0RcV3xn5jAawQN1dDnXYVNW8LAivwfkUazXjqxAf4IeMp92203kOjQij/D494etHYeIw4SNJgk7J2tDil+goITJ/OhJ4t7fDC0eA0nILn8ifyZZQUgZppW5CoAf1L8cxY1JWICXLKQFQ42zPkFaIA9oBSOgok5wlNoyguScJ70mqUwZewhZHk4L07WSFRbDEOawHe5CAHCPO8rVCkhk2WdLWRoY9uHijDxHn9eCZ3zm4ac/jAwPtFol43q5u9wTCSm8WmeVfU/mJLjgGvmyDhb5Z2fTVbWGsX/N/WHvASr85HfKS0Vq2hAHYozukLbJ8EQZL6ZoOiFhjbL1LJv6Ex3EZ3PTkjKZEGEiLS/aiLZSj95CDMnfKjaNnAN2qFxzR1yi7tFHttS7XiaTCuKoegeN/RNA1iTdFPsXIcmCklhqYr9jCoTaKOXic8W5C1ej3V8oogx1xA79/mf7ZtHxtHWeT9o7cG2EK5gYfvPi6bhKPZDQ2hq49tt8AbcjX4/ycovTmX/cTn0CCoUfLB7Ok9/UvdcdUiflVZEm4cH1WAXAeD3CW+WGTOEHSgNArl9ERxUyomsWhyhutGPmeZIPQ1COeuqFFBTIaHWDG1ytqN4= - distributions: sdist bdist_wheel - on: - tags: true + provider: pypi + user: "scls" + password: + secure: YP1I8vi04F2mhaexylEK4PoizOMKfPy6ixDGYYLg5WAvgNdHvBN39xFsd9WHavMGg0RcV3xn5jAawQN1dDnXYVNW8LAivwfkUazXjqxAf4IeMp92203kOjQij/D494etHYeIw4SNJgk7J2tDil+goITJ/OhJ4t7fDC0eA0nILn8ifyZZQUgZppW5CoAf1L8cxY1JWICXLKQFQ42zPkFaIA9oBSOgok5wlNoyguScJ70mqUwZewhZHk4L07WSFRbDEOawHe5CAHCPO8rVCkhk2WdLWRoY9uHijDxHn9eCZ3zm4ac/jAwPtFol43q5u9wTCSm8WmeVfU/mJLjgGvmyDhb5Z2fTVbWGsX/N/WHvASr85HfKS0Vq2hAHYozukLbJ8EQZL6ZoOiFhjbL1LJv6Ex3EZ3PTkjKZEGEiLS/aiLZSj95CDMnfKjaNnAN2qFxzR1yi7tFHttS7XiaTCuKoegeN/RNA1iTdFPsXIcmCklhqYr9jCoTaKOXic8W5C1ej3V8oogx1xA79/mf7ZtHxtHWeT9o7cG2EK5gYfvPi6bhKPZDQ2hq49tt8AbcjX4/ycovTmX/cTn0CCoUfLB7Ok9/UvdcdUiflVZEm4cH1WAXAeD3CW+WGTOEHSgNArl9ERxUyomsWhyhutGPmeZIPQ1COeuqFFBTIaHWDG1ytqN4= + distributions: sdist bdist_wheel + on: + tags: true diff --git a/.vscode/settings.json b/.vscode/settings.json new file mode 100644 index 0000000..92b75ca --- /dev/null +++ b/.vscode/settings.json @@ -0,0 +1,11 @@ +{ + "python.formatting.provider": "black", + "python.formatting.blackArgs": [ + "--config=pyproject.toml" + ], + "ruff.args": [ + "--config=pyproject.toml" + ], +"autoDocstring.docstringFormat": "google-notypes", +"esbonio.sphinx.confDir": "", +} diff --git a/CHANGELOG.md b/CHANGELOG.md index bcd0769..46d0bf3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,25 @@ All notable changes to this code base will be documented in this file, in every released version. +### Version 2.0.0 + +- Released: TBD +- Issues/Enhancements: + - Cythonized the package + - Added the `OptimizedBacktracking` solver based on [issue #62](https://github.com/python-constraint/python-constraint/issues/62) + - Added type-hints to improve Cythonization + - Added the MaxProd and MinProd constraints + - Improved pre-processing for the MaxSum constraint + - Optimized the Function constraint + - Added `getSolutionsOrderedList` and `getSolutionsAsListDict` functions for efficient result shaping + - Overall optimization of common bottlenecks + - Split the codebase into multiple files for convenience + - Switched from `setup.py` to `pyproject.toml` + - Achieved and requires test coverage of at least 65% + - Added `nox` for testing against all supported Python versions + - Added `ruff` for codestyle testing + - Dropped Python 3.4, 3.5, 3.6, 3.7 support + ### Version 1.4.0 - Released: 2018-11-05 diff --git a/README.rst b/README.rst index 564e46a..ae4c1b4 100644 --- a/README.rst +++ b/README.rst @@ -5,7 +5,8 @@ python-constraint Introduction ------------ -The Python constraint module offers solvers for `Constraint Satisfaction Problems (CSPs) `_ over finite domains in simple and pure Python. CSP is class of problems which may be represented in terms of variables (a, b, ...), domains (a in [1, 2, 3], ...), and constraints (a < b, ...). +The Python constraint module offers efficient solvers for `Constraint Satisfaction Problems (CSPs) `_ over finite domains in an accessible Python package. +CSP is class of problems which may be represented in terms of variables (a, b, ...), domains (a in [1, 2, 3], ...), and constraints (a < b, ...). Examples -------- @@ -13,7 +14,7 @@ Examples Basics ~~~~~~ -This interactive Python session demonstrates the module basic operation: +This interactive Python session demonstrates basic operations: .. code-block:: python @@ -97,10 +98,10 @@ Features The following solvers are available: - Backtracking solver +- Optimized backtracking solver - Recursive backtracking solver - Minimum conflicts solver - .. role:: python(code) :language: python @@ -109,9 +110,11 @@ Predefined constraint types currently available: - :python:`FunctionConstraint` - :python:`AllDifferentConstraint` - :python:`AllEqualConstraint` -- :python:`ExactSumConstraint` - :python:`MaxSumConstraint` +- :python:`ExactSumConstraint` - :python:`MinSumConstraint` +- :python:`MaxProdConstraint` +- :python:`MinProdConstraint` - :python:`InSetConstraint` - :python:`NotInSetConstraint` - :python:`SomeInSetConstraint` @@ -119,7 +122,8 @@ Predefined constraint types currently available: API documentation ----------------- -Documentation for the module is available at: http://labix.org/doc/constraint/ +Documentation for the module is available at: http://labix.org/doc/constraint/. +It can be built locally by running `make clean html` from the `documentation` folder. Download and install -------------------- @@ -128,6 +132,11 @@ Download and install $ pip install python-constraint +Testing +------- + +Run `pytest` (for local Python) or `nox` (for all supported Python versions). + Roadmap ------- @@ -146,6 +155,7 @@ Contact ------- - `Gustavo Niemeyer `_ - `Sébastien Celles `_ +- `Floris-Jan Willemsen ` But it's probably better to `open an issue `_. diff --git a/constraint/__init__.py b/constraint/__init__.py old mode 100755 new mode 100644 index 274270f..4ac46d0 --- a/constraint/__init__.py +++ b/constraint/__init__.py @@ -1,5 +1,8 @@ -#!/usr/bin/python -# +"""File for obtaining top-level imports from submodules. + +For example: constraint.problem.Problem can be imported as `from constraint import Problem`. +""" + # Copyright (c) 2005-2014 - Gustavo Niemeyer # # All rights reserved. @@ -24,1457 +27,11 @@ # OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE # OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. - -from __future__ import absolute_import, division, print_function - - -from .version import __author__ # noqa -from .version import __copyright__ # noqa -from .version import __credits__ # noqa -from .version import __license__ # noqa -from .version import __version__ # noqa -from .version import __email__ # noqa -from .version import __status__ # noqa -from .version import __url__ # noqa - -import random -import copy -from .compat import xrange - -__all__ = [ - "Problem", - "Variable", - "Domain", - "Unassigned", - "Solver", - "BacktrackingSolver", - "RecursiveBacktrackingSolver", - "MinConflictsSolver", - "Constraint", - "FunctionConstraint", - "AllDifferentConstraint", - "AllEqualConstraint", - "MaxSumConstraint", - "ExactSumConstraint", - "MinSumConstraint", - "InSetConstraint", - "NotInSetConstraint", - "SomeInSetConstraint", - "SomeNotInSetConstraint", -] - - -class Problem(object): - """ - Class used to define a problem and retrieve solutions - """ - - def __init__(self, solver=None): - """ - @param solver: Problem solver used to find solutions - (default is L{BacktrackingSolver}) - @type solver: instance of a L{Solver} subclass - """ - self._solver = solver or BacktrackingSolver() - self._constraints = [] - self._variables = {} - - def reset(self): - """ - Reset the current problem definition - - Example: - - >>> problem = Problem() - >>> problem.addVariable("a", [1, 2]) - >>> problem.reset() - >>> problem.getSolution() - >>> - """ - del self._constraints[:] - self._variables.clear() - - def setSolver(self, solver): - """ - Change the problem solver currently in use - - Example: - - >>> solver = BacktrackingSolver() - >>> problem = Problem(solver) - >>> problem.getSolver() is solver - True - - @param solver: New problem solver - @type solver: instance of a C{Solver} subclass - """ - self._solver = solver - - def getSolver(self): - """ - Obtain the problem solver currently in use - - Example: - - >>> solver = BacktrackingSolver() - >>> problem = Problem(solver) - >>> problem.getSolver() is solver - True - - @return: Solver currently in use - @rtype: instance of a L{Solver} subclass - """ - return self._solver - - def addVariable(self, variable, domain): - """ - Add a variable to the problem - - Example: - - >>> problem = Problem() - >>> problem.addVariable("a", [1, 2]) - >>> problem.getSolution() in ({'a': 1}, {'a': 2}) - True - - @param variable: Object representing a problem variable - @type variable: hashable object - @param domain: Set of items defining the possible values that - the given variable may assume - @type domain: list, tuple, or instance of C{Domain} - """ - if variable in self._variables: - msg = "Tried to insert duplicated variable %s" % repr(variable) - raise ValueError(msg) - if isinstance(domain, Domain): - domain = copy.deepcopy(domain) - elif hasattr(domain, "__getitem__"): - domain = Domain(domain) - else: - msg = "Domains must be instances of subclasses of the Domain class" - raise TypeError(msg) - if not domain: - raise ValueError("Domain is empty") - self._variables[variable] = domain - - def addVariables(self, variables, domain): - """ - Add one or more variables to the problem - - Example: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2, 3]) - >>> solutions = problem.getSolutions() - >>> len(solutions) - 9 - >>> {'a': 3, 'b': 1} in solutions - True - - @param variables: Any object containing a sequence of objects - represeting problem variables - @type variables: sequence of hashable objects - @param domain: Set of items defining the possible values that - the given variables may assume - @type domain: list, tuple, or instance of C{Domain} - """ - for variable in variables: - self.addVariable(variable, domain) - - def addConstraint(self, constraint, variables=None): - """ - Add a constraint to the problem - - Example: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2, 3]) - >>> problem.addConstraint(lambda a, b: b == a+1, ["a", "b"]) - >>> solutions = problem.getSolutions() - >>> - - @param constraint: Constraint to be included in the problem - @type constraint: instance a L{Constraint} subclass or a - function to be wrapped by L{FunctionConstraint} - @param variables: Variables affected by the constraint (default to - all variables). Depending on the constraint type - the order may be important. - @type variables: set or sequence of variables - """ - if not isinstance(constraint, Constraint): - if callable(constraint): - constraint = FunctionConstraint(constraint) - else: - msg = "Constraints must be instances of subclasses " "of the Constraint class" - raise ValueError(msg) - self._constraints.append((constraint, variables)) - - def getSolution(self): - """ - Find and return a solution to the problem - - Example: - - >>> problem = Problem() - >>> problem.getSolution() is None - True - >>> problem.addVariables(["a"], [42]) - >>> problem.getSolution() - {'a': 42} - - @return: Solution for the problem - @rtype: dictionary mapping variables to values - """ - domains, constraints, vconstraints = self._getArgs() - if not domains: - return None - return self._solver.getSolution(domains, constraints, vconstraints) - - def getSolutions(self): - """ - Find and return all solutions to the problem - - Example: - - >>> problem = Problem() - >>> problem.getSolutions() == [] - True - >>> problem.addVariables(["a"], [42]) - >>> problem.getSolutions() - [{'a': 42}] - - @return: All solutions for the problem - @rtype: list of dictionaries mapping variables to values - """ - domains, constraints, vconstraints = self._getArgs() - if not domains: - return [] - return self._solver.getSolutions(domains, constraints, vconstraints) - - def getSolutionIter(self): - """ - Return an iterator to the solutions of the problem - - Example: - - >>> problem = Problem() - >>> list(problem.getSolutionIter()) == [] - True - >>> problem.addVariables(["a"], [42]) - >>> iter = problem.getSolutionIter() - >>> next(iter) - {'a': 42} - >>> next(iter) - Traceback (most recent call last): - File "", line 1, in ? - StopIteration - """ - domains, constraints, vconstraints = self._getArgs() - if not domains: - return iter(()) - return self._solver.getSolutionIter(domains, constraints, vconstraints) - - def _getArgs(self): - domains = self._variables.copy() - allvariables = domains.keys() - constraints = [] - for constraint, variables in self._constraints: - if not variables: - variables = list(allvariables) - constraints.append((constraint, variables)) - vconstraints = {} - for variable in domains: - vconstraints[variable] = [] - for constraint, variables in constraints: - for variable in variables: - vconstraints[variable].append((constraint, variables)) - for constraint, variables in constraints[:]: - constraint.preProcess(variables, domains, constraints, vconstraints) - for domain in domains.values(): - domain.resetState() - if not domain: - return None, None, None - # doArc8(getArcs(domains, constraints), domains, {}) - return domains, constraints, vconstraints - - -# ---------------------------------------------------------------------- -# Solvers -# ---------------------------------------------------------------------- - - -def getArcs(domains, constraints): - """ - Return a dictionary mapping pairs (arcs) of constrained variables - - @attention: Currently unused. - """ - arcs = {} - for x in constraints: - constraint, variables = x - if len(variables) == 2: - variable1, variable2 = variables - arcs.setdefault(variable1, {}).setdefault(variable2, []).append(x) - arcs.setdefault(variable2, {}).setdefault(variable1, []).append(x) - return arcs - - -def doArc8(arcs, domains, assignments): - """ - Perform the ARC-8 arc checking algorithm and prune domains - - @attention: Currently unused. - """ - check = dict.fromkeys(domains, True) - while check: - variable, _ = check.popitem() - if variable not in arcs or variable in assignments: - continue - domain = domains[variable] - arcsvariable = arcs[variable] - for othervariable in arcsvariable: - arcconstraints = arcsvariable[othervariable] - if othervariable in assignments: - otherdomain = [assignments[othervariable]] - else: - otherdomain = domains[othervariable] - if domain: - # changed = False - for value in domain[:]: - assignments[variable] = value - if otherdomain: - for othervalue in otherdomain: - assignments[othervariable] = othervalue - for constraint, variables in arcconstraints: - if not constraint( - variables, domains, assignments, True - ): - break - else: - # All constraints passed. Value is safe. - break - else: - # All othervalues failed. Kill value. - domain.hideValue(value) - # changed = True - del assignments[othervariable] - del assignments[variable] - # if changed: - # check.update(dict.fromkeys(arcsvariable)) - if not domain: - return False - return True - - -class Solver(object): - """Abstract base class for solvers - """ - - def getSolution(self, domains, constraints, vconstraints): - """ - Return one solution for the given problem - - @param domains: Dictionary mapping variables to their domains - @type domains: dict - @param constraints: List of pairs of (constraint, variables) - @type constraints: list - @param vconstraints: Dictionary mapping variables to a list of - constraints affecting the given variables. - @type vconstraints: dict - """ - msg = "%s is an abstract class" % self.__class__.__name__ - raise NotImplementedError(msg) - - def getSolutions(self, domains, constraints, vconstraints): - """ - Return all solutions for the given problem - - @param domains: Dictionary mapping variables to domains - @type domains: dict - @param constraints: List of pairs of (constraint, variables) - @type constraints: list - @param vconstraints: Dictionary mapping variables to a list of - constraints affecting the given variables. - @type vconstraints: dict - """ - msg = "%s provides only a single solution" % self.__class__.__name__ - raise NotImplementedError(msg) - - def getSolutionIter(self, domains, constraints, vconstraints): - """ - Return an iterator for the solutions of the given problem - - @param domains: Dictionary mapping variables to domains - @type domains: dict - @param constraints: List of pairs of (constraint, variables) - @type constraints: list - @param vconstraints: Dictionary mapping variables to a list of - constraints affecting the given variables. - @type vconstraints: dict - """ - msg = "%s doesn't provide iteration" % self.__class__.__name__ - raise NotImplementedError(msg) - - -class BacktrackingSolver(Solver): - """ - Problem solver with backtracking capabilities - - Examples: - - >>> result = [[('a', 1), ('b', 2)], - ... [('a', 1), ('b', 3)], - ... [('a', 2), ('b', 3)]] - - >>> problem = Problem(BacktrackingSolver()) - >>> problem.addVariables(["a", "b"], [1, 2, 3]) - >>> problem.addConstraint(lambda a, b: b > a, ["a", "b"]) - - >>> solution = problem.getSolution() - >>> sorted(solution.items()) in result - True - - >>> for solution in problem.getSolutionIter(): - ... sorted(solution.items()) in result - True - True - True - - >>> for solution in problem.getSolutions(): - ... sorted(solution.items()) in result - True - True - True - """ - - def __init__(self, forwardcheck=True): - """ - @param forwardcheck: If false forward checking will not be requested - to constraints while looking for solutions - (default is true) - @type forwardcheck: bool - """ - self._forwardcheck = forwardcheck - - def getSolutionIter(self, domains, constraints, vconstraints): - forwardcheck = self._forwardcheck - assignments = {} - - queue = [] - - while True: - - # Mix the Degree and Minimum Remaing Values (MRV) heuristics - lst = [ - (-len(vconstraints[variable]), len(domains[variable]), variable) - for variable in domains - ] - lst.sort() - for item in lst: - if item[-1] not in assignments: - # Found unassigned variable - variable = item[-1] - values = domains[variable][:] - if forwardcheck: - pushdomains = [ - domains[x] - for x in domains - if x not in assignments and x != variable - ] - else: - pushdomains = None - break - else: - # No unassigned variables. We've got a solution. Go back - # to last variable, if there's one. - yield assignments.copy() - if not queue: - return - variable, values, pushdomains = queue.pop() - if pushdomains: - for domain in pushdomains: - domain.popState() - - while True: - # We have a variable. Do we have any values left? - if not values: - # No. Go back to last variable, if there's one. - del assignments[variable] - while queue: - variable, values, pushdomains = queue.pop() - if pushdomains: - for domain in pushdomains: - domain.popState() - if values: - break - del assignments[variable] - else: - return - - # Got a value. Check it. - assignments[variable] = values.pop() - - if pushdomains: - for domain in pushdomains: - domain.pushState() - - for constraint, variables in vconstraints[variable]: - if not constraint(variables, domains, assignments, pushdomains): - # Value is not good. - break - else: - break - - if pushdomains: - for domain in pushdomains: - domain.popState() - - # Push state before looking for next variable. - queue.append((variable, values, pushdomains)) - - raise RuntimeError("Can't happen") - - def getSolution(self, domains, constraints, vconstraints): - iter = self.getSolutionIter(domains, constraints, vconstraints) - try: - return next(iter) - except StopIteration: - return None - - def getSolutions(self, domains, constraints, vconstraints): - return list(self.getSolutionIter(domains, constraints, vconstraints)) - - -class RecursiveBacktrackingSolver(Solver): - """ - Recursive problem solver with backtracking capabilities - - Examples: - - >>> result = [[('a', 1), ('b', 2)], - ... [('a', 1), ('b', 3)], - ... [('a', 2), ('b', 3)]] - - >>> problem = Problem(RecursiveBacktrackingSolver()) - >>> problem.addVariables(["a", "b"], [1, 2, 3]) - >>> problem.addConstraint(lambda a, b: b > a, ["a", "b"]) - - >>> solution = problem.getSolution() - >>> sorted(solution.items()) in result - True - - >>> for solution in problem.getSolutions(): - ... sorted(solution.items()) in result - True - True - True - - >>> problem.getSolutionIter() - Traceback (most recent call last): - ... - NotImplementedError: RecursiveBacktrackingSolver doesn't provide iteration - """ - - def __init__(self, forwardcheck=True): - """ - @param forwardcheck: If false forward checking will not be requested - to constraints while looking for solutions - (default is true) - @type forwardcheck: bool - """ - self._forwardcheck = forwardcheck - - def recursiveBacktracking( - self, solutions, domains, vconstraints, assignments, single - ): - - # Mix the Degree and Minimum Remaing Values (MRV) heuristics - lst = [ - (-len(vconstraints[variable]), len(domains[variable]), variable) - for variable in domains - ] - lst.sort() - for item in lst: - if item[-1] not in assignments: - # Found an unassigned variable. Let's go. - break - else: - # No unassigned variables. We've got a solution. - solutions.append(assignments.copy()) - return solutions - - variable = item[-1] - assignments[variable] = None - - forwardcheck = self._forwardcheck - if forwardcheck: - pushdomains = [domains[x] for x in domains if x not in assignments] - else: - pushdomains = None - - for value in domains[variable]: - assignments[variable] = value - if pushdomains: - for domain in pushdomains: - domain.pushState() - for constraint, variables in vconstraints[variable]: - if not constraint(variables, domains, assignments, pushdomains): - # Value is not good. - break - else: - # Value is good. Recurse and get next variable. - self.recursiveBacktracking( - solutions, domains, vconstraints, assignments, single - ) - if solutions and single: - return solutions - if pushdomains: - for domain in pushdomains: - domain.popState() - del assignments[variable] - return solutions - - def getSolution(self, domains, constraints, vconstraints): - solutions = self.recursiveBacktracking([], domains, vconstraints, {}, True) - return solutions and solutions[0] or None - - def getSolutions(self, domains, constraints, vconstraints): - return self.recursiveBacktracking([], domains, vconstraints, {}, False) - - -class MinConflictsSolver(Solver): - """ - Problem solver based on the minimum conflicts theory - - Examples: - - >>> result = [[('a', 1), ('b', 2)], - ... [('a', 1), ('b', 3)], - ... [('a', 2), ('b', 3)]] - - >>> problem = Problem(MinConflictsSolver()) - >>> problem.addVariables(["a", "b"], [1, 2, 3]) - >>> problem.addConstraint(lambda a, b: b > a, ["a", "b"]) - - >>> solution = problem.getSolution() - >>> sorted(solution.items()) in result - True - - >>> problem.getSolutions() - Traceback (most recent call last): - ... - NotImplementedError: MinConflictsSolver provides only a single solution - - >>> problem.getSolutionIter() - Traceback (most recent call last): - ... - NotImplementedError: MinConflictsSolver doesn't provide iteration - """ - - def __init__(self, steps=1000): - """ - @param steps: Maximum number of steps to perform before giving up - when looking for a solution (default is 1000) - @type steps: int - """ - self._steps = steps - - def getSolution(self, domains, constraints, vconstraints): - assignments = {} - # Initial assignment - for variable in domains: - assignments[variable] = random.choice(domains[variable]) - for _ in xrange(self._steps): - conflicted = False - lst = list(domains.keys()) - random.shuffle(lst) - for variable in lst: - # Check if variable is not in conflict - for constraint, variables in vconstraints[variable]: - if not constraint(variables, domains, assignments): - break - else: - continue - # Variable has conflicts. Find values with less conflicts. - mincount = len(vconstraints[variable]) - minvalues = [] - for value in domains[variable]: - assignments[variable] = value - count = 0 - for constraint, variables in vconstraints[variable]: - if not constraint(variables, domains, assignments): - count += 1 - if count == mincount: - minvalues.append(value) - elif count < mincount: - mincount = count - del minvalues[:] - minvalues.append(value) - # Pick a random one from these values. - assignments[variable] = random.choice(minvalues) - conflicted = True - if not conflicted: - return assignments - return None - - -# ---------------------------------------------------------------------- -# Variables -# ---------------------------------------------------------------------- - - -class Variable(object): - """ - Helper class for variable definition - - Using this class is optional, since any hashable object, - including plain strings and integers, may be used as variables. - """ - - def __init__(self, name): - """ - @param name: Generic variable name for problem-specific purposes - @type name: string - """ - self.name = name - - def __repr__(self): - return self.name - - -Unassigned = Variable("Unassigned") #: Helper object instance representing unassigned values - - -# ---------------------------------------------------------------------- -# Domains -# ---------------------------------------------------------------------- - - -class Domain(list): - """ - Class used to control possible values for variables - - When list or tuples are used as domains, they are automatically - converted to an instance of that class. - """ - - def __init__(self, set): - """ - @param set: Set of values that the given variables may assume - @type set: set of objects comparable by equality - """ - list.__init__(self, set) - self._hidden = [] - self._states = [] - - def resetState(self): - """ - Reset to the original domain state, including all possible values - """ - self.extend(self._hidden) - del self._hidden[:] - del self._states[:] - - def pushState(self): - """ - Save current domain state - - Variables hidden after that call are restored when that state - is popped from the stack. - """ - self._states.append(len(self)) - - def popState(self): - """ - Restore domain state from the top of the stack - - Variables hidden since the last popped state are then available - again. - """ - diff = self._states.pop() - len(self) - if diff: - self.extend(self._hidden[-diff:]) - del self._hidden[-diff:] - - def hideValue(self, value): - """ - Hide the given value from the domain - - After that call the given value won't be seen as a possible value - on that domain anymore. The hidden value will be restored when the - previous saved state is popped. - - @param value: Object currently available in the domain - """ - list.remove(self, value) - self._hidden.append(value) - - -# ---------------------------------------------------------------------- -# Constraints -# ---------------------------------------------------------------------- - - -class Constraint(object): - """ - Abstract base class for constraints - """ - - def __call__(self, variables, domains, assignments, forwardcheck=False): - """ - Perform the constraint checking - - If the forwardcheck parameter is not false, besides telling if - the constraint is currently broken or not, the constraint - implementation may choose to hide values from the domains of - unassigned variables to prevent them from being used, and thus - prune the search space. - - @param variables: Variables affected by that constraint, in the - same order provided by the user - @type variables: sequence - @param domains: Dictionary mapping variables to their domains - @type domains: dict - @param assignments: Dictionary mapping assigned variables to their - current assumed value - @type assignments: dict - @param forwardcheck: Boolean value stating whether forward checking - should be performed or not - @return: Boolean value stating if this constraint is currently - broken or not - @rtype: bool - """ - return True - - def preProcess(self, variables, domains, constraints, vconstraints): - """ - Preprocess variable domains - - This method is called before starting to look for solutions, - and is used to prune domains with specific constraint logic - when possible. For instance, any constraints with a single - variable may be applied on all possible values and removed, - since they may act on individual values even without further - knowledge about other assignments. - - @param variables: Variables affected by that constraint, in the - same order provided by the user - @type variables: sequence - @param domains: Dictionary mapping variables to their domains - @type domains: dict - @param constraints: List of pairs of (constraint, variables) - @type constraints: list - @param vconstraints: Dictionary mapping variables to a list of - constraints affecting the given variables. - @type vconstraints: dict - """ - if len(variables) == 1: - variable = variables[0] - domain = domains[variable] - for value in domain[:]: - if not self(variables, domains, {variable: value}): - domain.remove(value) - constraints.remove((self, variables)) - vconstraints[variable].remove((self, variables)) - - def forwardCheck(self, variables, domains, assignments, _unassigned=Unassigned): - """ - Helper method for generic forward checking - - Currently, this method acts only when there's a single - unassigned variable. - - @param variables: Variables affected by that constraint, in the - same order provided by the user - @type variables: sequence - @param domains: Dictionary mapping variables to their domains - @type domains: dict - @param assignments: Dictionary mapping assigned variables to their - current assumed value - @type assignments: dict - @return: Boolean value stating if this constraint is currently - broken or not - @rtype: bool - """ - unassignedvariable = _unassigned - for variable in variables: - if variable not in assignments: - if unassignedvariable is _unassigned: - unassignedvariable = variable - else: - break - else: - if unassignedvariable is not _unassigned: - # Remove from the unassigned variable domain's all - # values which break our variable's constraints. - domain = domains[unassignedvariable] - if domain: - for value in domain[:]: - assignments[unassignedvariable] = value - if not self(variables, domains, assignments): - domain.hideValue(value) - del assignments[unassignedvariable] - if not domain: - return False - return True - - -class FunctionConstraint(Constraint): - """ - Constraint which wraps a function defining the constraint logic - - Examples: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2]) - >>> def func(a, b): - ... return b > a - >>> problem.addConstraint(func, ["a", "b"]) - >>> problem.getSolution() - {'a': 1, 'b': 2} - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2]) - >>> def func(a, b): - ... return b > a - >>> problem.addConstraint(FunctionConstraint(func), ["a", "b"]) - >>> problem.getSolution() - {'a': 1, 'b': 2} - """ - - def __init__(self, func, assigned=True): - """ - @param func: Function wrapped and queried for constraint logic - @type func: callable object - @param assigned: Whether the function may receive unassigned - variables or not - @type assigned: bool - """ - self._func = func - self._assigned = assigned - - def __call__( - self, - variables, - domains, - assignments, - forwardcheck=False, - _unassigned=Unassigned, - ): - parms = [assignments.get(x, _unassigned) for x in variables] - missing = parms.count(_unassigned) - if missing: - return (self._assigned or self._func(*parms)) and ( - not forwardcheck or - missing != 1 or - self.forwardCheck(variables, domains, assignments) - ) - return self._func(*parms) - - -class AllDifferentConstraint(Constraint): - """ - Constraint enforcing that values of all given variables are different - - Example: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2]) - >>> problem.addConstraint(AllDifferentConstraint()) - >>> sorted(sorted(x.items()) for x in problem.getSolutions()) - [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)]] - """ - - def __call__( - self, - variables, - domains, - assignments, - forwardcheck=False, - _unassigned=Unassigned, - ): - seen = {} - for variable in variables: - value = assignments.get(variable, _unassigned) - if value is not _unassigned: - if value in seen: - return False - seen[value] = True - if forwardcheck: - for variable in variables: - if variable not in assignments: - domain = domains[variable] - for value in seen: - if value in domain: - domain.hideValue(value) - if not domain: - return False - return True - - -class AllEqualConstraint(Constraint): - """ - Constraint enforcing that values of all given variables are equal - - Example: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2]) - >>> problem.addConstraint(AllEqualConstraint()) - >>> sorted(sorted(x.items()) for x in problem.getSolutions()) - [[('a', 1), ('b', 1)], [('a', 2), ('b', 2)]] - """ - - def __call__( - self, - variables, - domains, - assignments, - forwardcheck=False, - _unassigned=Unassigned, - ): - singlevalue = _unassigned - for variable in variables: - value = assignments.get(variable, _unassigned) - if singlevalue is _unassigned: - singlevalue = value - elif value is not _unassigned and value != singlevalue: - return False - if forwardcheck and singlevalue is not _unassigned: - for variable in variables: - if variable not in assignments: - domain = domains[variable] - if singlevalue not in domain: - return False - for value in domain[:]: - if value != singlevalue: - domain.hideValue(value) - return True - - -class MaxSumConstraint(Constraint): - """ - Constraint enforcing that values of given variables sum up to - a given amount - - Example: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2]) - >>> problem.addConstraint(MaxSumConstraint(3)) - >>> sorted(sorted(x.items()) for x in problem.getSolutions()) - [[('a', 1), ('b', 1)], [('a', 1), ('b', 2)], [('a', 2), ('b', 1)]] - """ - - def __init__(self, maxsum, multipliers=None): - """ - @param maxsum: Value to be considered as the maximum sum - @type maxsum: number - @param multipliers: If given, variable values will be multiplied by - the given factors before being summed to be checked - @type multipliers: sequence of numbers - """ - self._maxsum = maxsum - self._multipliers = multipliers - - def preProcess(self, variables, domains, constraints, vconstraints): - Constraint.preProcess(self, variables, domains, constraints, vconstraints) - multipliers = self._multipliers - maxsum = self._maxsum - if multipliers: - for variable, multiplier in zip(variables, multipliers): - domain = domains[variable] - for value in domain[:]: - if value * multiplier > maxsum: - domain.remove(value) - else: - for variable in variables: - domain = domains[variable] - for value in domain[:]: - if value > maxsum: - domain.remove(value) - - def __call__(self, variables, domains, assignments, forwardcheck=False): - multipliers = self._multipliers - maxsum = self._maxsum - sum = 0 - if multipliers: - for variable, multiplier in zip(variables, multipliers): - if variable in assignments: - sum += assignments[variable] * multiplier - if type(sum) is float: - sum = round(sum, 10) - if sum > maxsum: - return False - if forwardcheck: - for variable, multiplier in zip(variables, multipliers): - if variable not in assignments: - domain = domains[variable] - for value in domain[:]: - if sum + value * multiplier > maxsum: - domain.hideValue(value) - if not domain: - return False - else: - for variable in variables: - if variable in assignments: - sum += assignments[variable] - if type(sum) is float: - sum = round(sum, 10) - if sum > maxsum: - return False - if forwardcheck: - for variable in variables: - if variable not in assignments: - domain = domains[variable] - for value in domain[:]: - if sum + value > maxsum: - domain.hideValue(value) - if not domain: - return False - return True - - -class ExactSumConstraint(Constraint): - """ - Constraint enforcing that values of given variables sum exactly - to a given amount - - Example: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2]) - >>> problem.addConstraint(ExactSumConstraint(3)) - >>> sorted(sorted(x.items()) for x in problem.getSolutions()) - [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)]] - """ - - def __init__(self, exactsum, multipliers=None): - """ - @param exactsum: Value to be considered as the exact sum - @type exactsum: number - @param multipliers: If given, variable values will be multiplied by - the given factors before being summed to be checked - @type multipliers: sequence of numbers - """ - self._exactsum = exactsum - self._multipliers = multipliers - - def preProcess(self, variables, domains, constraints, vconstraints): - Constraint.preProcess(self, variables, domains, constraints, vconstraints) - multipliers = self._multipliers - exactsum = self._exactsum - if multipliers: - for variable, multiplier in zip(variables, multipliers): - domain = domains[variable] - for value in domain[:]: - if value * multiplier > exactsum: - domain.remove(value) - else: - for variable in variables: - domain = domains[variable] - for value in domain[:]: - if value > exactsum: - domain.remove(value) - - def __call__(self, variables, domains, assignments, forwardcheck=False): - multipliers = self._multipliers - exactsum = self._exactsum - sum = 0 - missing = False - if multipliers: - for variable, multiplier in zip(variables, multipliers): - if variable in assignments: - sum += assignments[variable] * multiplier - else: - missing = True - if type(sum) is float: - sum = round(sum, 10) - if sum > exactsum: - return False - if forwardcheck and missing: - for variable, multiplier in zip(variables, multipliers): - if variable not in assignments: - domain = domains[variable] - for value in domain[:]: - if sum + value * multiplier > exactsum: - domain.hideValue(value) - if not domain: - return False - else: - for variable in variables: - if variable in assignments: - sum += assignments[variable] - else: - missing = True - if type(sum) is float: - sum = round(sum, 10) - if sum > exactsum: - return False - if forwardcheck and missing: - for variable in variables: - if variable not in assignments: - domain = domains[variable] - for value in domain[:]: - if sum + value > exactsum: - domain.hideValue(value) - if not domain: - return False - if missing: - return sum <= exactsum - else: - return sum == exactsum - - -class MinSumConstraint(Constraint): - """ - Constraint enforcing that values of given variables sum at least - to a given amount - - Example: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2]) - >>> problem.addConstraint(MinSumConstraint(3)) - >>> sorted(sorted(x.items()) for x in problem.getSolutions()) - [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)], [('a', 2), ('b', 2)]] - """ - - def __init__(self, minsum, multipliers=None): - """ - @param minsum: Value to be considered as the minimum sum - @type minsum: number - @param multipliers: If given, variable values will be multiplied by - the given factors before being summed to be checked - @type multipliers: sequence of numbers - """ - self._minsum = minsum - self._multipliers = multipliers - - def __call__(self, variables, domains, assignments, forwardcheck=False): - for variable in variables: - if variable not in assignments: - return True - else: - multipliers = self._multipliers - minsum = self._minsum - sum = 0 - if multipliers: - for variable, multiplier in zip(variables, multipliers): - sum += assignments[variable] * multiplier - else: - for variable in variables: - sum += assignments[variable] - if type(sum) is float: - sum = round(sum, 10) - return sum >= minsum - - -class InSetConstraint(Constraint): - """ - Constraint enforcing that values of given variables are present in - the given set - - Example: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2]) - >>> problem.addConstraint(InSetConstraint([1])) - >>> sorted(sorted(x.items()) for x in problem.getSolutions()) - [[('a', 1), ('b', 1)]] - """ - - def __init__(self, set): - """ - @param set: Set of allowed values - @type set: set - """ - self._set = set - - def __call__(self, variables, domains, assignments, forwardcheck=False): - # preProcess() will remove it. - raise RuntimeError("Can't happen") - - def preProcess(self, variables, domains, constraints, vconstraints): - set = self._set - for variable in variables: - domain = domains[variable] - for value in domain[:]: - if value not in set: - domain.remove(value) - vconstraints[variable].remove((self, variables)) - constraints.remove((self, variables)) - - -class NotInSetConstraint(Constraint): - """ - Constraint enforcing that values of given variables are not present in - the given set - - Example: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2]) - >>> problem.addConstraint(NotInSetConstraint([1])) - >>> sorted(sorted(x.items()) for x in problem.getSolutions()) - [[('a', 2), ('b', 2)]] - """ - - def __init__(self, set): - """ - @param set: Set of disallowed values - @type set: set - """ - self._set = set - - def __call__(self, variables, domains, assignments, forwardcheck=False): - # preProcess() will remove it. - raise RuntimeError("Can't happen") - - def preProcess(self, variables, domains, constraints, vconstraints): - set = self._set - for variable in variables: - domain = domains[variable] - for value in domain[:]: - if value in set: - domain.remove(value) - vconstraints[variable].remove((self, variables)) - constraints.remove((self, variables)) - - -class SomeInSetConstraint(Constraint): - """ - Constraint enforcing that at least some of the values of given - variables must be present in a given set - - Example: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2]) - >>> problem.addConstraint(SomeInSetConstraint([1])) - >>> sorted(sorted(x.items()) for x in problem.getSolutions()) - [[('a', 1), ('b', 1)], [('a', 1), ('b', 2)], [('a', 2), ('b', 1)]] - """ - - def __init__(self, set, n=1, exact=False): - """ - @param set: Set of values to be checked - @type set: set - @param n: Minimum number of assigned values that should be present - in set (default is 1) - @type n: int - @param exact: Whether the number of assigned values which are - present in set must be exactly C{n} - @type exact: bool - """ - self._set = set - self._n = n - self._exact = exact - - def __call__(self, variables, domains, assignments, forwardcheck=False): - set = self._set - missing = 0 - found = 0 - for variable in variables: - if variable in assignments: - found += assignments[variable] in set - else: - missing += 1 - if missing: - if self._exact: - if not (found <= self._n <= missing + found): - return False - else: - if self._n > missing + found: - return False - if forwardcheck and self._n - found == missing: - # All unassigned variables must be assigned to - # values in the set. - for variable in variables: - if variable not in assignments: - domain = domains[variable] - for value in domain[:]: - if value not in set: - domain.hideValue(value) - if not domain: - return False - else: - if self._exact: - if found != self._n: - return False - else: - if found < self._n: - return False - return True - - -class SomeNotInSetConstraint(Constraint): - """ - Constraint enforcing that at least some of the values of given - variables must not be present in a given set - - Example: - - >>> problem = Problem() - >>> problem.addVariables(["a", "b"], [1, 2]) - >>> problem.addConstraint(SomeNotInSetConstraint([1])) - >>> sorted(sorted(x.items()) for x in problem.getSolutions()) - [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)], [('a', 2), ('b', 2)]] - """ - - def __init__(self, set, n=1, exact=False): - """ - @param set: Set of values to be checked - @type set: set - @param n: Minimum number of assigned values that should not be present - in set (default is 1) - @type n: int - @param exact: Whether the number of assigned values which are - not present in set must be exactly C{n} - @type exact: bool - """ - self._set = set - self._n = n - self._exact = exact - - def __call__(self, variables, domains, assignments, forwardcheck=False): - set = self._set - missing = 0 - found = 0 - for variable in variables: - if variable in assignments: - found += assignments[variable] not in set - else: - missing += 1 - if missing: - if self._exact: - if not (found <= self._n <= missing + found): - return False - else: - if self._n > missing + found: - return False - if forwardcheck and self._n - found == missing: - # All unassigned variables must be assigned to - # values not in the set. - for variable in variables: - if variable not in assignments: - domain = domains[variable] - for value in domain[:]: - if value in set: - domain.hideValue(value) - if not domain: - return False - else: - if self._exact: - if found != self._n: - return False - else: - if found < self._n: - return False - return True - +from constraint.problem import * # noqa: F403 +from constraint.domain import * # noqa: F403 +from constraint.constraints import * # noqa: F403 +from constraint.solvers import * # noqa: F403 if __name__ == "__main__": - import doctest - - doctest.testmod() + from tests import test_doctests + test_doctests() diff --git a/constraint/compat.py b/constraint/compat.py deleted file mode 100644 index c20c223..0000000 --- a/constraint/compat.py +++ /dev/null @@ -1,14 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -import sys - -PY2 = sys.version_info[0] == 2 -PY3 = sys.version_info[0] >= 3 - -if PY3: - string_types = str - xrange = range -else: - string_types = basestring # noqa - xrange = xrange diff --git a/constraint/constraints.py b/constraint/constraints.py new file mode 100644 index 0000000..477a1c4 --- /dev/null +++ b/constraint/constraints.py @@ -0,0 +1,785 @@ +"""Module containing the code for constraint definitions.""" + +from constraint.domain import Unassigned +from typing import Callable, List, Union, Optional, Sequence + +class Constraint(object): + """Abstract base class for constraints.""" + + def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): + """Perform the constraint checking. + + If the forwardcheck parameter is not false, besides telling if + the constraint is currently broken or not, the constraint + implementation may choose to hide values from the domains of + unassigned variables to prevent them from being used, and thus + prune the search space. + + Args: + variables (sequence): :py:class:`Variables` affected by that constraint, + in the same order provided by the user + domains (dict): Dictionary mapping variables to their + domains + assignments (dict): Dictionary mapping assigned variables to + their current assumed value + forwardcheck: Boolean value stating whether forward checking + should be performed or not + + Returns: + bool: Boolean value stating if this constraint is currently + broken or not + """ + return True + + def preProcess(self, variables: Sequence, domains: dict, constraints: List[tuple], vconstraints: dict): + """Preprocess variable domains. + + This method is called before starting to look for solutions, + and is used to prune domains with specific constraint logic + when possible. For instance, any constraints with a single + variable may be applied on all possible values and removed, + since they may act on individual values even without further + knowledge about other assignments. + + Args: + variables (sequence): Variables affected by that constraint, + in the same order provided by the user + domains (dict): Dictionary mapping variables to their + domains + constraints (list): List of pairs of (constraint, variables) + vconstraints (dict): Dictionary mapping variables to a list + of constraints affecting the given variables. + """ + if len(variables) == 1: + variable = variables[0] + domain = domains[variable] + for value in domain[:]: + if not self(variables, domains, {variable: value}): + domain.remove(value) + constraints.remove((self, variables)) + vconstraints[variable].remove((self, variables)) + + def forwardCheck(self, variables: Sequence, domains: dict, assignments: dict, _unassigned=Unassigned): + """Helper method for generic forward checking. + + Currently, this method acts only when there's a single + unassigned variable. + + Args: + variables (sequence): Variables affected by that constraint, + in the same order provided by the user + domains (dict): Dictionary mapping variables to their + domains + assignments (dict): Dictionary mapping assigned variables to + their current assumed value + + Returns: + bool: Boolean value stating if this constraint is currently + broken or not + """ + unassignedvariable = _unassigned + for variable in variables: + if variable not in assignments: + if unassignedvariable is _unassigned: + unassignedvariable = variable + else: + break + else: + if unassignedvariable is not _unassigned: + # Remove from the unassigned variable domain's all + # values which break our variable's constraints. + domain = domains[unassignedvariable] + if domain: + for value in domain[:]: + assignments[unassignedvariable] = value + if not self(variables, domains, assignments): + domain.hideValue(value) + del assignments[unassignedvariable] + if not domain: + return False + return True + + +class FunctionConstraint(Constraint): + """Constraint which wraps a function defining the constraint logic. + + Examples: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2]) + >>> def func(a, b): + ... return b > a + >>> problem.addConstraint(func, ["a", "b"]) + >>> problem.getSolution() + {'a': 1, 'b': 2} + + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2]) + >>> def func(a, b): + ... return b > a + >>> problem.addConstraint(FunctionConstraint(func), ["a", "b"]) + >>> problem.getSolution() + {'a': 1, 'b': 2} + """ + + def __init__(self, func: Callable, assigned: bool = True): + """Initialization method. + + Args: + func (callable object): Function wrapped and queried for + constraint logic + assigned (bool): Whether the function may receive unassigned + variables or not + """ + self._func = func + self._assigned = assigned + + def __call__( # noqa: D102 + self, + variables: Sequence, + domains: dict, + assignments: dict, + forwardcheck=False, + _unassigned=Unassigned, + ): + # # initial code: 0.94621 seconds, Cythonized: 0.92805 seconds + # parms = [assignments.get(x, _unassigned) for x in variables] + # missing = parms.count(_unassigned) + + # # list comprehension and sum: 0.13744 seconds, Cythonized: 0.10059 seconds + # parms = [assignments.get(x, _unassigned) for x in variables] + # missing = sum(x not in assignments for x in variables) + + # # sum check with fallback: , Cythonized: 0.10108 seconds + # missing = sum(x not in assignments for x in variables) + # parms = [assignments.get(x, _unassigned) for x in variables] if missing > 0 else [assignments[x] for x in var] + + # # tuple list comprehension with unzipping: 0.14521 seconds, Cythonized: 0.12054 seconds + # lst = [(assignments[x], 0) if x in assignments else (_unassigned, 1) for x in variables] + # parms, missing_iter = zip(*lst) + # parms = list(parms) + # missing = sum(missing_iter) + + # # single loop array: 0.11249 seconds, Cythonized: 0.09514 seconds + # parms = [None] * len(variables) + # missing = 0 + # for i, x in enumerate(variables): + # if x in assignments: + # parms[i] = assignments[x] + # else: + # parms[i] = _unassigned + # missing += 1 + + # single loop list: 0.11462 seconds, Cythonized: 0.08686 seconds + parms = list() + missing = 0 + for x in variables: + if x in assignments: + parms.append(assignments[x]) + else: + parms.append(_unassigned) + missing += 1 + + # if there are unassigned variables, do a forward check before executing the restriction function + if missing > 0: + return (self._assigned or self._func(*parms)) and ( + not forwardcheck or missing != 1 or self.forwardCheck(variables, domains, assignments) + ) + return self._func(*parms) + + +class AllDifferentConstraint(Constraint): + """Constraint enforcing that values of all given variables are different. + + Example: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2]) + >>> problem.addConstraint(AllDifferentConstraint()) + >>> sorted(sorted(x.items()) for x in problem.getSolutions()) + [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)]] + """ + + def __call__( # noqa: D102 + self, + variables: Sequence, + domains: dict, + assignments: dict, + forwardcheck=False, + _unassigned=Unassigned, + ): + seen = {} + for variable in variables: + value = assignments.get(variable, _unassigned) + if value is not _unassigned: + if value in seen: + return False + seen[value] = True + if forwardcheck: + for variable in variables: + if variable not in assignments: + domain = domains[variable] + for value in seen: + if value in domain: + domain.hideValue(value) + if not domain: + return False + return True + + +class AllEqualConstraint(Constraint): + """Constraint enforcing that values of all given variables are equal. + + Example: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2]) + >>> problem.addConstraint(AllEqualConstraint()) + >>> sorted(sorted(x.items()) for x in problem.getSolutions()) + [[('a', 1), ('b', 1)], [('a', 2), ('b', 2)]] + """ + + def __call__( # noqa: D102 + self, + variables: Sequence, + domains: dict, + assignments: dict, + forwardcheck=False, + _unassigned=Unassigned, + ): + singlevalue = _unassigned + for variable in variables: + value = assignments.get(variable, _unassigned) + if singlevalue is _unassigned: + singlevalue = value + elif value is not _unassigned and value != singlevalue: + return False + if forwardcheck and singlevalue is not _unassigned: + for variable in variables: + if variable not in assignments: + domain = domains[variable] + if singlevalue not in domain: + return False + for value in domain[:]: + if value != singlevalue: + domain.hideValue(value) + return True + + +class MaxSumConstraint(Constraint): + """Constraint enforcing that values of given variables sum up to a given amount. + + Example: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2]) + >>> problem.addConstraint(MaxSumConstraint(3)) + >>> sorted(sorted(x.items()) for x in problem.getSolutions()) + [[('a', 1), ('b', 1)], [('a', 1), ('b', 2)], [('a', 2), ('b', 1)]] + """ + + def __init__(self, maxsum: Union[int, float], multipliers: Optional[Sequence] = None): + """Initialization method. + + Args: + maxsum (number): Value to be considered as the maximum sum + multipliers (sequence of numbers): If given, variable values + will be multiplied by the given factors before being + summed to be checked + """ + self._maxsum = maxsum + self._multipliers = multipliers + + def preProcess(self, variables: Sequence, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + Constraint.preProcess(self, variables, domains, constraints, vconstraints) + + # check if there are any negative values in the associated variables + variable_contains_negative: list[bool] = list() + variable_with_negative = None + for variable in variables: + contains_negative = any(value < 0 for value in domains[variable]) + variable_contains_negative.append(contains_negative) + if contains_negative: + if variable_with_negative is not None: + # if more than one associated variables contain negative, we can't prune + return + variable_with_negative = variable + + # prune the associated variables of values > maxsum + multipliers = self._multipliers + maxsum = self._maxsum + if multipliers: + for variable, multiplier in zip(variables, multipliers): + if variable_with_negative is not None and variable_with_negative != variable: + continue + domain = domains[variable] + for value in domain[:]: + if value * multiplier > maxsum: + domain.remove(value) + else: + for variable in variables: + if variable_with_negative is not None and variable_with_negative != variable: + continue + domain = domains[variable] + for value in domain[:]: + if value > maxsum: + domain.remove(value) + + def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102 + multipliers = self._multipliers + maxsum = self._maxsum + sum = 0 + if multipliers: + for variable, multiplier in zip(variables, multipliers): + if variable in assignments: + sum += assignments[variable] * multiplier + if isinstance(sum, float): + sum = round(sum, 10) + if sum > maxsum: + return False + if forwardcheck: + for variable, multiplier in zip(variables, multipliers): + if variable not in assignments: + domain = domains[variable] + for value in domain[:]: + if sum + value * multiplier > maxsum: + domain.hideValue(value) + if not domain: + return False + else: + for variable in variables: + if variable in assignments: + sum += assignments[variable] + if isinstance(sum, float): + sum = round(sum, 10) + if sum > maxsum: + return False + if forwardcheck: + for variable in variables: + if variable not in assignments: + domain = domains[variable] + for value in domain[:]: + if sum + value > maxsum: + domain.hideValue(value) + if not domain: + return False + return True + + +class ExactSumConstraint(Constraint): + """Constraint enforcing that values of given variables sum exactly to a given amount. + + Example: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2]) + >>> problem.addConstraint(ExactSumConstraint(3)) + >>> sorted(sorted(x.items()) for x in problem.getSolutions()) + [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)]] + """ + + def __init__(self, exactsum: Union[int, float], multipliers: Optional[Sequence] = None): + """Initialization method. + + Args: + exactsum (number): Value to be considered as the exact sum + multipliers (sequence of numbers): If given, variable values + will be multiplied by the given factors before being + summed to be checked + """ + self._exactsum = exactsum + self._multipliers = multipliers + + def preProcess(self, variables: Sequence, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + Constraint.preProcess(self, variables, domains, constraints, vconstraints) + multipliers = self._multipliers + exactsum = self._exactsum + if multipliers: + for variable, multiplier in zip(variables, multipliers): + domain = domains[variable] + for value in domain[:]: + if value * multiplier > exactsum: + domain.remove(value) + else: + for variable in variables: + domain = domains[variable] + for value in domain[:]: + if value > exactsum: + domain.remove(value) + + def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102 + multipliers = self._multipliers + exactsum = self._exactsum + sum = 0 + missing = False + if multipliers: + for variable, multiplier in zip(variables, multipliers): + if variable in assignments: + sum += assignments[variable] * multiplier + else: + missing = True + if isinstance(sum, float): + sum = round(sum, 10) + if sum > exactsum: + return False + if forwardcheck and missing: + for variable, multiplier in zip(variables, multipliers): + if variable not in assignments: + domain = domains[variable] + for value in domain[:]: + if sum + value * multiplier > exactsum: + domain.hideValue(value) + if not domain: + return False + else: + for variable in variables: + if variable in assignments: + sum += assignments[variable] + else: + missing = True + if isinstance(sum, float): + sum = round(sum, 10) + if sum > exactsum: + return False + if forwardcheck and missing: + for variable in variables: + if variable not in assignments: + domain = domains[variable] + for value in domain[:]: + if sum + value > exactsum: + domain.hideValue(value) + if not domain: + return False + if missing: + return sum <= exactsum + else: + return sum == exactsum + + +class MinSumConstraint(Constraint): + """Constraint enforcing that values of given variables sum at least to a given amount. + + Example: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2]) + >>> problem.addConstraint(MinSumConstraint(3)) + >>> sorted(sorted(x.items()) for x in problem.getSolutions()) + [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)], [('a', 2), ('b', 2)]] + """ + + def __init__(self, minsum: Union[int, float], multipliers: Optional[Sequence] = None): + """Initialization method. + + Args: + minsum (number): Value to be considered as the minimum sum + multipliers (sequence of numbers): If given, variable values + will be multiplied by the given factors before being + summed to be checked + """ + self._minsum = minsum + self._multipliers = multipliers + + def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102 + # check if each variable is in the assignments + for variable in variables: + if variable not in assignments: + return True + + # with each variable assigned, sum the values + multipliers = self._multipliers + minsum = self._minsum + sum = 0 + if multipliers: + for variable, multiplier in zip(variables, multipliers): + sum += assignments[variable] * multiplier + else: + for variable in variables: + sum += assignments[variable] + if isinstance(sum, float): + sum = round(sum, 10) + return sum >= minsum + + +class MaxProdConstraint(Constraint): + """Constraint enforcing that values of given variables create a product up to at most a given amount.""" + + def __init__(self, maxprod: Union[int, float]): + """Instantiate a MaxProdConstraint. + + Args: + maxprod: Value to be considered as the maximum product + """ + self._maxprod = maxprod + + def preProcess(self, variables: Sequence, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + Constraint.preProcess(self, variables, domains, constraints, vconstraints) + + # check if there are any values less than 1 in the associated variables + variable_contains_lt1: list[bool] = list() + variable_with_lt1 = None + for variable in variables: + contains_lt1 = any(value < 1 for value in domains[variable]) + variable_contains_lt1.append(contains_lt1) + if contains_lt1 is True: + if variable_with_lt1 is not None: + # if more than one associated variables contain less than 1, we can't prune + return + variable_with_lt1 = variable + + # prune the associated variables of values > maxprod + maxprod = self._maxprod + for variable in variables: + if variable_with_lt1 is not None and variable_with_lt1 != variable: + continue + domain = domains[variable] + for value in domain[:]: + if value > maxprod: + domain.remove(value) + elif value == 0 and maxprod < 0: + domain.remove(value) + + def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102 + maxprod = self._maxprod + prod = 1 + for variable in variables: + if variable in assignments: + prod *= assignments[variable] + if isinstance(prod, float): + prod = round(prod, 10) + if prod > maxprod: + return False + if forwardcheck: + for variable in variables: + if variable not in assignments: + domain = domains[variable] + for value in domain[:]: + if prod * value > maxprod: + domain.hideValue(value) + if not domain: + return False + return True + + +class MinProdConstraint(Constraint): + """Constraint enforcing that values of given variables create a product up to at least a given amount.""" + + def __init__(self, minprod: Union[int, float]): + """Instantiate a MinProdConstraint. + + Args: + minprod: Value to be considered as the maximum product + """ + self._minprod = minprod + + def preProcess(self, variables: Sequence, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + Constraint.preProcess(self, variables, domains, constraints, vconstraints) + + # prune the associated variables of values > maxprod + minprod = self._minprod + for variable in variables: + domain = domains[variable] + for value in domain[:]: + if value == 0 and minprod > 0: + domain.remove(value) + + def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102 + # check if each variable is in the assignments + for variable in variables: + if variable not in assignments: + return True + + # with each variable assigned, sum the values + minprod = self._minprod + prod = 1 + for variable in variables: + prod *= assignments[variable] + if isinstance(prod, float): + prod = round(prod, 10) + return prod >= minprod + + +class InSetConstraint(Constraint): + """Constraint enforcing that values of given variables are present in the given set. + + Example: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2]) + >>> problem.addConstraint(InSetConstraint([1])) + >>> sorted(sorted(x.items()) for x in problem.getSolutions()) + [[('a', 1), ('b', 1)]] + """ + + def __init__(self, set): + """Initialization method. + + Args: + set (set): Set of allowed values + """ + self._set = set + + def __call__(self, variables, domains, assignments, forwardcheck=False): # noqa: D102 + # preProcess() will remove it. + raise RuntimeError("Can't happen") + + def preProcess(self, variables: Sequence, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + set = self._set + for variable in variables: + domain = domains[variable] + for value in domain[:]: + if value not in set: + domain.remove(value) + vconstraints[variable].remove((self, variables)) + constraints.remove((self, variables)) + + +class NotInSetConstraint(Constraint): + """Constraint enforcing that values of given variables are not present in the given set. + + Example: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2]) + >>> problem.addConstraint(NotInSetConstraint([1])) + >>> sorted(sorted(x.items()) for x in problem.getSolutions()) + [[('a', 2), ('b', 2)]] + """ + + def __init__(self, set): + """Initialization method. + + Args: + set (set): Set of disallowed values + """ + self._set = set + + def __call__(self, variables, domains, assignments, forwardcheck=False): # noqa: D102 + # preProcess() will remove it. + raise RuntimeError("Can't happen") + + def preProcess(self, variables: Sequence, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + set = self._set + for variable in variables: + domain = domains[variable] + for value in domain[:]: + if value in set: + domain.remove(value) + vconstraints[variable].remove((self, variables)) + constraints.remove((self, variables)) + + +class SomeInSetConstraint(Constraint): + """Constraint enforcing that at least some of the values of given variables must be present in a given set. + + Example: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2]) + >>> problem.addConstraint(SomeInSetConstraint([1])) + >>> sorted(sorted(x.items()) for x in problem.getSolutions()) + [[('a', 1), ('b', 1)], [('a', 1), ('b', 2)], [('a', 2), ('b', 1)]] + """ + + def __init__(self, set, n=1, exact=False): + """Initialization method. + + Args: + set (set): Set of values to be checked + n (int): Minimum number of assigned values that should be + present in set (default is 1) + exact (bool): Whether the number of assigned values which + are present in set must be exactly `n` + """ + self._set = set + self._n = n + self._exact = exact + + def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102 + set = self._set + missing = 0 + found = 0 + for variable in variables: + if variable in assignments: + found += assignments[variable] in set + else: + missing += 1 + if missing: + if self._exact: + if not (found <= self._n <= missing + found): + return False + else: + if self._n > missing + found: + return False + if forwardcheck and self._n - found == missing: + # All unassigned variables must be assigned to + # values in the set. + for variable in variables: + if variable not in assignments: + domain = domains[variable] + for value in domain[:]: + if value not in set: + domain.hideValue(value) + if not domain: + return False + else: + if self._exact: + if found != self._n: + return False + else: + if found < self._n: + return False + return True + + +class SomeNotInSetConstraint(Constraint): + """Constraint enforcing that at least some of the values of given variables must not be present in a given set. + + Example: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2]) + >>> problem.addConstraint(SomeNotInSetConstraint([1])) + >>> sorted(sorted(x.items()) for x in problem.getSolutions()) + [[('a', 1), ('b', 2)], [('a', 2), ('b', 1)], [('a', 2), ('b', 2)]] + """ + + def __init__(self, set, n=1, exact=False): + """Initialization method. + + Args: + set (set): Set of values to be checked + n (int): Minimum number of assigned values that should not + be present in set (default is 1) + exact (bool): Whether the number of assigned values which + are not present in set must be exactly `n` + """ + self._set = set + self._n = n + self._exact = exact + + def __call__(self, variables: Sequence, domains: dict, assignments: dict, forwardcheck=False): # noqa: D102 + set = self._set + missing = 0 + found = 0 + for variable in variables: + if variable in assignments: + found += assignments[variable] not in set + else: + missing += 1 + if missing: + if self._exact: + if not (found <= self._n <= missing + found): + return False + else: + if self._n > missing + found: + return False + if forwardcheck and self._n - found == missing: + # All unassigned variables must be assigned to + # values not in the set. + for variable in variables: + if variable not in assignments: + domain = domains[variable] + for value in domain[:]: + if value in set: + domain.hideValue(value) + if not domain: + return False + else: + if self._exact: + if found != self._n: + return False + else: + if found < self._n: + return False + return True diff --git a/constraint/domain.py b/constraint/domain.py new file mode 100644 index 0000000..8c9f09d --- /dev/null +++ b/constraint/domain.py @@ -0,0 +1,97 @@ +"""Module containing the code for the Variable and Domain classes.""" + +def check_if_compiled() -> bool: + """Check if this code has been compiled with Cython. + + Returns: + bool: whether the code has been compiled. + """ + from cython import compiled + + return compiled + +# ---------------------------------------------------------------------- +# Variables +# ---------------------------------------------------------------------- + + +class Variable(object): + """Helper class for variable definition. + + Using this class is optional, since any hashable object, + including plain strings and integers, may be used as variables. + """ + + def __init__(self, name): + """Initialization method. + + Args: + name (string): Generic variable name for problem-specific + purposes + """ + self.name = name + + def __repr__(self): + """Represents itself with the name attribute.""" + return self.name + + +Unassigned = Variable("Unassigned") #: Helper object instance representing unassigned values + + +# ---------------------------------------------------------------------- +# Domains +# ---------------------------------------------------------------------- + + +class Domain(list): + """Class used to control possible values for variables. + + When list or tuples are used as domains, they are automatically + converted to an instance of that class. + """ + + def __init__(self, set): + """Initialization method. + + Args: + set: Set of values, comparable by equality, that the given variables may assume. + """ + list.__init__(self, set) + self._hidden = [] + self._states = [] + + def resetState(self): + """Reset to the original domain state, including all possible values.""" + self.extend(self._hidden) + del self._hidden[:] + del self._states[:] + + def pushState(self): + """Save current domain state. + + Variables hidden after that call are restored when that state is popped from the stack. + """ + self._states.append(len(self)) + + def popState(self): + """Restore domain state from the top of the stack. + + Variables hidden since the last popped state are then available again. + """ + diff = self._states.pop() - len(self) + if diff: + self.extend(self._hidden[-diff:]) + del self._hidden[-diff:] + + def hideValue(self, value): + """Hide the given value from the domain. + + After that call the given value won't be seen as a possible value on that domain anymore. + The hidden value will be restored when the previous saved state is popped. + + Args: + value: Object currently available in the domain + """ + list.remove(self, value) + self._hidden.append(value) diff --git a/constraint/problem.py b/constraint/problem.py new file mode 100644 index 0000000..20d8cc4 --- /dev/null +++ b/constraint/problem.py @@ -0,0 +1,252 @@ +"""Module containing the code for problem definitions.""" + +import copy + +from constraint.solvers import BacktrackingSolver +from constraint.domain import Domain +from constraint.constraints import Constraint, FunctionConstraint +from operator import itemgetter +from typing import List, Optional, Union, Sequence, Tuple, Dict, Callable + +class Problem(object): + """Class used to define a problem and retrieve solutions.""" + + def __init__(self, solver=None): + """Initialization method. + + Args: + solver (instance of a :py:class:`Solver`): Problem solver to use (default is :py:class:`BacktrackingSolver`) + """ + self._solver = solver or BacktrackingSolver() + self._constraints = [] + self._variables = {} + + def reset(self): + """Reset the current problem definition. + + Example: + >>> problem = Problem() + >>> problem.addVariable("a", [1, 2]) + >>> problem.reset() + >>> problem.getSolution() + >>> + """ + del self._constraints[:] + self._variables.clear() + + def setSolver(self, solver): + """Change the problem solver currently in use. + + Example: + >>> solver = BacktrackingSolver() + >>> problem = Problem(solver) + >>> problem.getSolver() is solver + True + + Args: + solver (instance of a :py:class:`Solver`): New problem + solver + """ + self._solver = solver + + def getSolver(self): + """Obtain the problem solver currently in use. + + Example: + >>> solver = BacktrackingSolver() + >>> problem = Problem(solver) + >>> problem.getSolver() is solver + True + + Returns: + instance of a :py:class:`Solver` subclass: Solver currently in use + """ + return self._solver + + def addVariable(self, variable, domain): + """Add a variable to the problem. + + Example: + >>> problem = Problem() + >>> problem.addVariable("a", [1, 2]) + >>> problem.getSolution() in ({'a': 1}, {'a': 2}) + True + + Args: + variable (hashable object): Object representing a problem + variable + domain (list, tuple, or instance of :py:class:`Domain`): Set of items + defining the possible values that the given variable may + assume + """ + if variable in self._variables: + msg = "Tried to insert duplicated variable %s" % repr(variable) + raise ValueError(msg) + if isinstance(domain, Domain): + domain = copy.deepcopy(domain) + elif hasattr(domain, "__getitem__"): + domain = Domain(domain) + else: + msg = "Domains must be instances of subclasses of the Domain class" + raise TypeError(msg) + if not domain: + raise ValueError("Domain is empty") + self._variables[variable] = domain + + def addVariables(self, variables: Sequence, domain): + """Add one or more variables to the problem. + + Example: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2, 3]) + >>> solutions = problem.getSolutions() + >>> len(solutions) + 9 + >>> {'a': 3, 'b': 1} in solutions + True + + Args: + variables (sequence of hashable objects): Any object + containing a sequence of objects represeting problem + variables + domain (list, tuple, or instance of :py:class:`Domain`): Set of items + defining the possible values that the given variables + may assume + """ + for variable in variables: + self.addVariable(variable, domain) + + def addConstraint(self, constraint: Union[Constraint, Callable], variables: Optional[Sequence] = None): + """Add a constraint to the problem. + + Example: + >>> problem = Problem() + >>> problem.addVariables(["a", "b"], [1, 2, 3]) + >>> problem.addConstraint(lambda a, b: b == a+1, ["a", "b"]) + >>> solutions = problem.getSolutions() + >>> + + Args: + constraint (instance of :py:class:`Constraint` or function to be wrapped by :py:class:`FunctionConstraint`): + Constraint to be included in the problem + variables (set or sequence of variables): :py:class:`Variables` affected + by the constraint (default to all variables). Depending + on the constraint type the order may be important. + """ + if not isinstance(constraint, Constraint): + if callable(constraint): + constraint = FunctionConstraint(constraint) + else: + msg = "Constraints must be instances of subclasses " "of the Constraint class" + raise ValueError(msg) + self._constraints.append((constraint, variables)) + + def getSolution(self): + """Find and return a solution to the problem. + + Example: + >>> problem = Problem() + >>> problem.getSolution() is None + True + >>> problem.addVariables(["a"], [42]) + >>> problem.getSolution() + {'a': 42} + + Returns: + dictionary mapping variables to values: Solution for the + problem + """ + domains, constraints, vconstraints = self._getArgs() + if not domains: + return None + return self._solver.getSolution(domains, constraints, vconstraints) + + def getSolutions(self): + """Find and return all solutions to the problem. + + Example: + >>> problem = Problem() + >>> problem.getSolutions() == [] + True + >>> problem.addVariables(["a"], [42]) + >>> problem.getSolutions() + [{'a': 42}] + + Returns: + list of dictionaries mapping variables to values: All + solutions for the problem + """ + domains, constraints, vconstraints = self._getArgs() + if not domains: + return [] + return self._solver.getSolutions(domains, constraints, vconstraints) + + def getSolutionIter(self): + """Return an iterator to the solutions of the problem. + + Example: + >>> problem = Problem() + >>> list(problem.getSolutionIter()) == [] + True + >>> problem.addVariables(["a"], [42]) + >>> iter = problem.getSolutionIter() + >>> next(iter) + {'a': 42} + >>> next(iter) + Traceback (most recent call last): + ... + StopIteration + """ + domains, constraints, vconstraints = self._getArgs() + if not domains: + return iter(()) + return self._solver.getSolutionIter(domains, constraints, vconstraints) + + def getSolutionsOrderedList(self, order: List[str] = None) -> List[tuple]: + """Returns the solutions as a list of tuples, with each solution tuple ordered according to `order`.""" + solutions: List[dict] = self.getSolutions() + if order is None or len(order) == 1: + return list(tuple(solution.values()) for solution in solutions) + get_in_order = itemgetter(*order) + return list(get_in_order(params) for params in solutions) + + def getSolutionsAsListDict(self, order: List[str] = None, validate: bool = True) -> Tuple[List[tuple], Dict[tuple, int], int]: # noqa: E501 + """Returns the searchspace as a list of tuples, a dict of the searchspace for fast lookups and the size.""" + solutions_list = self.getSolutionsOrderedList(order) + size_list = len(solutions_list) + solutions_dict: dict = dict(zip(solutions_list, range(size_list))) + if validate: + # check for duplicates + size_dict = len(solutions_dict) + if size_list != size_dict: + raise ValueError( + f"{size_list - size_dict} duplicate parameter configurations in searchspace, should not happen." + ) + return ( + solutions_list, + solutions_dict, + size_list, + ) + + def _getArgs(self): + domains = self._variables.copy() + allvariables = domains.keys() + constraints = [] + for constraint, variables in self._constraints: + if not variables: + variables = list(allvariables) + constraints.append((constraint, variables)) + vconstraints = {} + for variable in domains: + vconstraints[variable] = [] + for constraint, variables in constraints: + for variable in variables: + vconstraints[variable].append((constraint, variables)) + for constraint, variables in constraints[:]: + constraint.preProcess(variables, domains, constraints, vconstraints) + for domain in domains.values(): + domain.resetState() + if not domain: + return None, None, None + # doArc8(getArcs(domains, constraints), domains, {}) + return domains, constraints, vconstraints diff --git a/constraint/solvers.py b/constraint/solvers.py new file mode 100644 index 0000000..95d08ea --- /dev/null +++ b/constraint/solvers.py @@ -0,0 +1,593 @@ +"""Module containing the code for the problem solvers.""" + +import random +from typing import List + + +def getArcs(domains: dict, constraints: List[tuple]) -> dict: + """Return a dictionary mapping pairs (arcs) of constrained variables. + + @attention: Currently unused. + """ + arcs = {} + for x in constraints: + constraint, variables = x + if len(variables) == 2: + variable1, variable2 = variables + arcs.setdefault(variable1, {}).setdefault(variable2, []).append(x) + arcs.setdefault(variable2, {}).setdefault(variable1, []).append(x) + return arcs + + +def doArc8(arcs: dict, domains: dict, assignments: dict) -> bool: + """Perform the ARC-8 arc checking algorithm and prune domains. + + @attention: Currently unused. + """ + check = dict.fromkeys(domains, True) + while check: + variable, _ = check.popitem() + if variable not in arcs or variable in assignments: + continue + domain = domains[variable] + arcsvariable = arcs[variable] + for othervariable in arcsvariable: + arcconstraints = arcsvariable[othervariable] + if othervariable in assignments: + otherdomain = [assignments[othervariable]] + else: + otherdomain = domains[othervariable] + if domain: + # changed = False + for value in domain[:]: + assignments[variable] = value + if otherdomain: + for othervalue in otherdomain: + assignments[othervariable] = othervalue + for constraint, variables in arcconstraints: + if not constraint(variables, domains, assignments, True): + break + else: + # All constraints passed. Value is safe. + break + else: + # All othervalues failed. Kill value. + domain.hideValue(value) + # changed = True + del assignments[othervariable] + del assignments[variable] + # if changed: + # check.update(dict.fromkeys(arcsvariable)) + if not domain: + return False + return True + + +class Solver(object): + """Abstract base class for solvers.""" + + def getSolution(self, domains: dict, constraints: List[tuple], vconstraints: dict): + """Return one solution for the given problem. + + Args: + domains (dict): Dictionary mapping variables to their domains + constraints (list): List of pairs of (constraint, variables) + vconstraints (dict): Dictionary mapping variables to a list + of constraints affecting the given variables. + """ + msg = "%s is an abstract class" % self.__class__.__name__ + raise NotImplementedError(msg) + + def getSolutions(self, domains: dict, constraints: List[tuple], vconstraints: dict): + """Return all solutions for the given problem. + + Args: + domains (dict): Dictionary mapping variables to domains + constraints (list): List of pairs of (constraint, variables) + vconstraints (dict): Dictionary mapping variables to a list + of constraints affecting the given variables. + """ + msg = "%s provides only a single solution" % self.__class__.__name__ + raise NotImplementedError(msg) + + def getSolutionIter(self, domains: dict, constraints: List[tuple], vconstraints: dict): + """Return an iterator for the solutions of the given problem. + + Args: + domains (dict): Dictionary mapping variables to domains + constraints (list): List of pairs of (constraint, variables) + vconstraints (dict): Dictionary mapping variables to a list + of constraints affecting the given variables. + """ + msg = "%s doesn't provide iteration" % self.__class__.__name__ + raise NotImplementedError(msg) + + +class BacktrackingSolver(Solver): + """Problem solver with backtracking capabilities. + + Examples: + >>> result = [[('a', 1), ('b', 2)], + ... [('a', 1), ('b', 3)], + ... [('a', 2), ('b', 3)]] + + >>> problem = Problem(BacktrackingSolver()) + >>> problem.addVariables(["a", "b"], [1, 2, 3]) + >>> problem.addConstraint(lambda a, b: b > a, ["a", "b"]) + + >>> solution = problem.getSolution() + >>> sorted(solution.items()) in result + True + + >>> for solution in problem.getSolutionIter(): + ... sorted(solution.items()) in result + True + True + True + + >>> for solution in problem.getSolutions(): + ... sorted(solution.items()) in result + True + True + True + """ + + def __init__(self, forwardcheck=True): + """Initialization method. + + Args: + forwardcheck (bool): If false forward checking will not be + requested to constraints while looking for solutions + (default is true) + """ + self._forwardcheck = forwardcheck + + def getSolutionIter(self, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + forwardcheck = self._forwardcheck + assignments = {} + + queue = [] + + while True: + # Mix the Degree and Minimum Remaing Values (MRV) heuristics + lst = [(-len(vconstraints[variable]), len(domains[variable]), variable) for variable in domains] + lst.sort() + for item in lst: + if item[-1] not in assignments: + # Found unassigned variable + variable = item[-1] + values = domains[variable][:] + if forwardcheck: + pushdomains = [domains[x] for x in domains if x not in assignments and x != variable] + else: + pushdomains = None + break + else: + # No unassigned variables. We've got a solution. Go back + # to last variable, if there's one. + yield assignments.copy() + if not queue: + return + variable, values, pushdomains = queue.pop() + if pushdomains: + for domain in pushdomains: + domain.popState() + + while True: + # We have a variable. Do we have any values left? + if not values: + # No. Go back to last variable, if there's one. + del assignments[variable] + while queue: + variable, values, pushdomains = queue.pop() + if pushdomains: + for domain in pushdomains: + domain.popState() + if values: + break + del assignments[variable] + else: + return + + # Got a value. Check it. + assignments[variable] = values.pop() + + if pushdomains: + for domain in pushdomains: + domain.pushState() + + for constraint, variables in vconstraints[variable]: + if not constraint(variables, domains, assignments, pushdomains): + # Value is not good. + break + else: + break + + if pushdomains: + for domain in pushdomains: + domain.popState() + + # Push state before looking for next variable. + queue.append((variable, values, pushdomains)) + + raise RuntimeError("Can't happen") + + def getSolution(self, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + iter = self.getSolutionIter(domains, constraints, vconstraints) + try: + return next(iter) + except StopIteration: + return None + + def getSolutions(self, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + return list(self.getSolutionIter(domains, constraints, vconstraints)) + +class OptimizedBacktrackingSolver(Solver): + """Problem solver with backtracking capabilities, implementing several optimizations for increased performance. + + Optimizations are especially in obtaining all solutions. + View https://github.com/python-constraint/python-constraint/pull/76 for more details. + + Examples: + >>> result = [[('a', 1), ('b', 2)], + ... [('a', 1), ('b', 3)], + ... [('a', 2), ('b', 3)]] + + >>> problem = Problem(OptimizedBacktrackingSolver()) + >>> problem.addVariables(["a", "b"], [1, 2, 3]) + >>> problem.addConstraint(lambda a, b: b > a, ["a", "b"]) + + >>> solution = problem.getSolution() + >>> sorted(solution.items()) in result + True + + >>> for solution in problem.getSolutionIter(): + ... sorted(solution.items()) in result + True + True + True + + >>> for solution in problem.getSolutions(): + ... sorted(solution.items()) in result + True + True + True + """ + + def __init__(self, forwardcheck=True): + """Initialization method. + + Args: + forwardcheck (bool): If false forward checking will not be + requested to constraints while looking for solutions + (default is true) + """ + self._forwardcheck = forwardcheck + + def getSolutionIter(self, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + forwardcheck = self._forwardcheck + assignments = {} + sorted_variables = self.getSortedVariables(domains, vconstraints) + + queue = [] + + while True: + # Mix the Degree and Minimum Remaing Values (MRV) heuristics + for variable in sorted_variables: + if variable not in assignments: + # Found unassigned variable + values = domains[variable][:] + if forwardcheck: + pushdomains = [domains[x] for x in domains if x not in assignments and x != variable] + else: + pushdomains = None + break + else: + # No unassigned variables. We've got a solution. Go back + # to last variable, if there's one. + yield assignments.copy() + if not queue: + return + variable, values, pushdomains = queue.pop() + if pushdomains: + for domain in pushdomains: + domain.popState() + + while True: + # We have a variable. Do we have any values left? + if not values: + # No. Go back to last variable, if there's one. + del assignments[variable] + while queue: + variable, values, pushdomains = queue.pop() + if pushdomains: + for domain in pushdomains: + domain.popState() + if values: + break + del assignments[variable] + else: + return + + # Got a value. Check it. + assignments[variable] = values.pop() + + if pushdomains: + for domain in pushdomains: + domain.pushState() + + for constraint, variables in vconstraints[variable]: + if not constraint(variables, domains, assignments, pushdomains): + # Value is not good. + break + else: + break + + if pushdomains: + for domain in pushdomains: + domain.popState() + + # Push state before looking for next variable. + queue.append((variable, values, pushdomains)) + + raise RuntimeError("Can't happen") + + def getSolutionsList(self, domains: dict, vconstraints: dict) -> List[dict]: # noqa: D102 + """Optimized all-solutions finder that skips forwardchecking and returns the solutions in a list. + + Args: + domains: Dictionary mapping variables to domains + vconstraints: Dictionary mapping variables to a list of constraints affecting the given variables. + + Returns: + the list of solutions as a dictionary. + """ + # Does not do forwardcheck for simplicity + assignments: dict = {} + queue: List[tuple] = [] + solutions: List[dict] = list() + sorted_variables = self.getSortedVariables(domains, vconstraints) + + while True: + # Mix the Degree and Minimum Remaing Values (MRV) heuristics + for variable in sorted_variables: + if variable not in assignments: + # Found unassigned variable + values = domains[variable][:] + break + else: + # No unassigned variables. We've got a solution. Go back + # to last variable, if there's one. + solutions.append(assignments.copy()) + if not queue: + return solutions + variable, values = queue.pop() + + while True: + # We have a variable. Do we have any values left? + if not values: + # No. Go back to last variable, if there's one. + del assignments[variable] + while queue: + variable, values = queue.pop() + if values: + break + del assignments[variable] + else: + return solutions + + # Got a value. Check it. + assignments[variable] = values.pop() + for constraint, variables in vconstraints[variable]: + if not constraint(variables, domains, assignments, None): + # Value is not good. + break + else: + break + + # Push state before looking for next variable. + queue.append((variable, values)) + + raise RuntimeError("Can't happen") + + + def getSolutions(self, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + if self._forwardcheck: + return list(self.getSolutionIter(domains, constraints, vconstraints)) + return self.getSolutionsList(domains, vconstraints) + + def getSolution(self, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + iter = self.getSolutionIter(domains, constraints, vconstraints) + try: + return next(iter) + except StopIteration: + return None + + def getSortedVariables(self, domains: dict, vconstraints: dict) -> list: + """Sorts the list of variables on number of vconstraints to find unassigned variables quicker. + + Args: + domains: Dictionary mapping variables to their domains + vconstraints: Dictionary mapping variables to a list + of constraints affecting the given variables. + + Returns: + the list of variables, sorted from highest number of vconstraints to lowest. + """ + lst = [(-len(vconstraints[variable]), len(domains[variable]), variable) for variable in domains] + lst.sort() + return [c for _, _, c in lst] + + +class RecursiveBacktrackingSolver(Solver): + """Recursive problem solver with backtracking capabilities. + + Examples: + >>> result = [[('a', 1), ('b', 2)], + ... [('a', 1), ('b', 3)], + ... [('a', 2), ('b', 3)]] + + >>> problem = Problem(RecursiveBacktrackingSolver()) + >>> problem.addVariables(["a", "b"], [1, 2, 3]) + >>> problem.addConstraint(lambda a, b: b > a, ["a", "b"]) + + >>> solution = problem.getSolution() + >>> sorted(solution.items()) in result + True + + >>> for solution in problem.getSolutions(): + ... sorted(solution.items()) in result + True + True + True + + >>> problem.getSolutionIter() + Traceback (most recent call last): + ... + NotImplementedError: RecursiveBacktrackingSolver doesn't provide iteration + """ + + def __init__(self, forwardcheck=True): + """Initialization method. + + Args: + forwardcheck (bool): If false forward checking will not be + requested to constraints while looking for solutions + (default is true) + """ + self._forwardcheck = forwardcheck + + def recursiveBacktracking(self, solutions, domains, vconstraints, assignments, single): + """Mix the Degree and Minimum Remaing Values (MRV) heuristics. + + Args: + solutions: _description_ + domains: _description_ + vconstraints: _description_ + assignments: _description_ + single: _description_ + + Returns: + _description_ + """ + lst = [(-len(vconstraints[variable]), len(domains[variable]), variable) for variable in domains] + lst.sort() + for item in lst: + if item[-1] not in assignments: + # Found an unassigned variable. Let's go. + break + else: + # No unassigned variables. We've got a solution. + solutions.append(assignments.copy()) + return solutions + + variable = item[-1] + assignments[variable] = None + + forwardcheck = self._forwardcheck + if forwardcheck: + pushdomains = [domains[x] for x in domains if x not in assignments] + else: + pushdomains = None + + for value in domains[variable]: + assignments[variable] = value + if pushdomains: + for domain in pushdomains: + domain.pushState() + for constraint, variables in vconstraints[variable]: + if not constraint(variables, domains, assignments, pushdomains): + # Value is not good. + break + else: + # Value is good. Recurse and get next variable. + self.recursiveBacktracking(solutions, domains, vconstraints, assignments, single) + if solutions and single: + return solutions + if pushdomains: + for domain in pushdomains: + domain.popState() + del assignments[variable] + return solutions + + def getSolution(self, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + solutions = self.recursiveBacktracking([], domains, vconstraints, {}, True) + return solutions and solutions[0] or None + + def getSolutions(self, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + return self.recursiveBacktracking([], domains, vconstraints, {}, False) + + +class MinConflictsSolver(Solver): + """Problem solver based on the minimum conflicts theory. + + Examples: + >>> result = [[('a', 1), ('b', 2)], + ... [('a', 1), ('b', 3)], + ... [('a', 2), ('b', 3)]] + + >>> problem = Problem(MinConflictsSolver()) + >>> problem.addVariables(["a", "b"], [1, 2, 3]) + >>> problem.addConstraint(lambda a, b: b > a, ["a", "b"]) + + >>> solution = problem.getSolution() + >>> sorted(solution.items()) in result + True + + >>> problem.getSolutions() + Traceback (most recent call last): + ... + NotImplementedError: MinConflictsSolver provides only a single solution + + >>> problem.getSolutionIter() + Traceback (most recent call last): + ... + NotImplementedError: MinConflictsSolver doesn't provide iteration + """ + + def __init__(self, steps=1000): + """Initialization method. + + Args: + steps (int): Maximum number of steps to perform before + giving up when looking for a solution (default is 1000) + """ + self._steps = steps + + def getSolution(self, domains: dict, constraints: List[tuple], vconstraints: dict): # noqa: D102 + assignments = {} + # Initial assignment + for variable in domains: + assignments[variable] = random.choice(domains[variable]) + for _ in range(self._steps): + conflicted = False + lst = list(domains.keys()) + random.shuffle(lst) + for variable in lst: + # Check if variable is not in conflict + for constraint, variables in vconstraints[variable]: + if not constraint(variables, domains, assignments): + break + else: + continue + # Variable has conflicts. Find values with less conflicts. + mincount = len(vconstraints[variable]) + minvalues = [] + for value in domains[variable]: + assignments[variable] = value + count = 0 + for constraint, variables in vconstraints[variable]: + if not constraint(variables, domains, assignments): + count += 1 + if count == mincount: + minvalues.append(value) + elif count < mincount: + mincount = count + del minvalues[:] + minvalues.append(value) + # Pick a random one from these values. + assignments[variable] = random.choice(minvalues) + conflicted = True + if not conflicted: + return assignments + return None diff --git a/constraint/version.py b/constraint/version.py deleted file mode 100644 index b3dddc1..0000000 --- a/constraint/version.py +++ /dev/null @@ -1,8 +0,0 @@ -__author__ = "Gustavo Niemeyer" -__copyright__ = "Copyright (c) 2005-2018 - Gustavo Niemeyer " -__credits__ = ["Sebastien Celles"] -__license__ = "" -__version__ = "1.4.0" -__email__ = "gustavo@niemeyer.net" -__status__ = "Development" -__url__ = "https://github.com/python-constraint/python-constraint" diff --git a/documentation/.gitignore b/documentation/.gitignore new file mode 100644 index 0000000..69fa449 --- /dev/null +++ b/documentation/.gitignore @@ -0,0 +1 @@ +_build/ diff --git a/documentation/Makefile b/documentation/Makefile index 110776e..d4bb2cb 100644 --- a/documentation/Makefile +++ b/documentation/Makefile @@ -1,12 +1,12 @@ # Minimal makefile for Sphinx documentation # -# You can set these variables from the command line. -SPHINXOPTS = -SPHINXBUILD = sphinx-build -SPHINXPROJ = python-constraint -SOURCEDIR = source -BUILDDIR = build +# You can set these variables from the command line, and also +# from the environment for the first two. +SPHINXOPTS ?= +SPHINXBUILD ?= sphinx-build +SOURCEDIR = . +BUILDDIR = _build # Put it first so that "make" without argument is like "make help". help: diff --git a/documentation/source/conf.py b/documentation/conf.py similarity index 77% rename from documentation/source/conf.py rename to documentation/conf.py index afff0ba..a5cf9b4 100644 --- a/documentation/source/conf.py +++ b/documentation/conf.py @@ -8,100 +8,72 @@ # All configuration values have a default; values that are commented out # serve to show the default. -import sys, os +import os +import sys +import time + +from sphinx_pyproject import SphinxConfig # If extensions (or modules to document with autodoc) are in another directory, # add these directories to sys.path here. If the directory is relative to the # documentation root, use os.path.abspath to make it absolute, like shown here. -sys.path.insert(0, os.path.abspath('../..')) +sys.path.insert(0, os.path.abspath('../constraint/')) + +# -- Project information ----------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#project-information + +# import data from pyproject.toml using https://github.com/sphinx-toolbox/sphinx-pyproject +# additional data can be added with `[tool.sphinx-pyproject]` and retrieved with `config['']`. +config = SphinxConfig("../pyproject.toml") # add `, globalns=globals()` to directly insert in namespace +year = time.strftime("%Y") + +project = "python-constraint" +author = config.author +copyright = f"{year}, {author}" +version = config.version # major version (e.g. 2.6) +release = config.version # full version (e.g. 2.6rc1) # -- General configuration ----------------------------------------------------- # If your documentation needs a minimal Sphinx version, state it here. -#needs_sphinx = '1.0' +needs_sphinx = '7.2' # Add any Sphinx extension module names here, as strings. They can be extensions # coming with Sphinx (named 'sphinx.ext.*') or your custom ones. extensions = [ - 'sphinx.ext.autodoc', - 'sphinx.ext.doctest', - 'sphinx.ext.todo', - 'sphinx.ext.coverage', - 'sphinx.ext.imgmath', + "sphinx.ext.autosummary", + 'sphinx.ext.autodoc', + 'sphinx_autodoc_typehints', # must be after autodoc + 'sphinx.ext.doctest', + 'sphinx.ext.todo', + 'sphinx.ext.coverage', + 'sphinx.ext.imgmath', 'sphinx.ext.viewcode', 'sphinx.ext.napoleon', 'sphinx.ext.intersphinx', - ] +] + +autosummary_generate = True # Turn on sphinx.ext.autosummary +autoclass_content = "both" # concatenate class doctrings and __init__ method docstrings +exclude_patterns = ["_build", "Thumbs.db", ".DS_Store"] +# map objects / types to external documentation (e.g. python objects -> python docs, numpy objects -> numpy docs) intersphinx_mapping = { 'python': ('https://docs.python.org/3', None), } -# ------------------------------------------------------------------------------ - -# The following is the content of -# https://github.com/jayvdb/sphinx-epytext/blob/master/sphinx_epytext/process_docstring.py -# which is licensed under the MIT license. -import re - -FIELDS = [ - 'param', - 'keyword', - 'kwarg', - 'type', - 'returns', - 'return', - 'rtype', - 'raise', - 'raises', - 'exception', - 'see', - 'note', - # not tested - 'attention', - 'bug', - 'warning', - 'version', - 'todo', - 'deprecated', - 'since', - 'status', - 'change', - 'permission', - 'requires', - 'precondition', - 'postcondition', - 'invariant', - 'author', - 'organization', - 'copyright', - 'license', - 'contact', - 'summary', -] - -# Not supported yet: 'group', 'sort' +# -- Options for HTML output ------------------------------------------------- +# https://www.sphinx-doc.org/en/master/usage/configuration.html#options-for-html-output - -def process_docstring(app, what, name, obj, options, lines): - """ - Process the docstring for a given python object. - Note that the list 'lines' is changed in this function. Sphinx - uses the altered content of the list. - """ - result = [re.sub(r'U\{([^}]*)\}', r'\1', - re.sub(r'(L|C)\{([^}]*)\}', r':py:obj:`\2`', - re.sub(r'@(' + '|'.join(FIELDS) + r')', r':\1', - l))) - for l in lines] - lines[:] = result[:] +html_theme = "sphinx_rtd_theme" +html_static_path = ["_static"] +# html_logo = "source/logo_autotuning_methodology.svg" +# html_theme_options = { +# "logo_only": True, +# } # ------------------------------------------------------------------------------ -def setup(app): - app.connect('autodoc-process-docstring', process_docstring) - - # Add any paths that contain templates here, relative to this directory. templates_path = ['_templates'] @@ -112,24 +84,11 @@ def setup(app): #source_encoding = 'utf-8-sig' # The master toctree document. -master_doc = 'index' - -# General information about the project. -project = u'python-constraint' -copyright = u'2005-2014, Gustavo Niemeyer' - -# The version info for the project you're documenting, acts as replacement for -# |version| and |release|, also used in various other places throughout the -# built documents. -# -# The short X.Y version. -version = '1.4.0' -# The full version, including alpha/beta/rc tags. -release = '1.4.0' +master_doc = 'source/index' # The language for content autogenerated by Sphinx. Refer to documentation -# for a list of supported languages. -#language = None +# for a list of supported languages (https://www.sphinx-doc.org/en/master/usage/configuration.html#confval-language). +language = 'en' # There are two options for replacing |today|: either, you set today to some # non-false value, then it is used: @@ -139,7 +98,7 @@ def setup(app): # List of patterns, relative to source directory, that match files and # directories to ignore when looking for source files. -exclude_patterns = [] +exclude_patterns = ['*.c', '*.so'] # The reST default role (used for this markup: `text`) to use for all documents. #default_role = None @@ -156,7 +115,7 @@ def setup(app): #show_authors = False # The name of the Pygments (syntax highlighting) style to use. -pygments_style = 'sphinx' +# pygments_style = 'sphinx' # A list of ignored prefixes for module index sorting. #modindex_common_prefix = [] @@ -169,7 +128,7 @@ def setup(app): # The theme to use for HTML and HTML Help pages. See the documentation for # a list of builtin themes. -html_theme = 'alabaster' +# html_theme = 'alabaster' # Theme options are theme-specific and customize the look and feel of a theme # further. For a list of options available for each theme, see the diff --git a/documentation/source/index.rst b/documentation/source/index.rst index 2aa53d8..b9dbbbe 100644 --- a/documentation/source/index.rst +++ b/documentation/source/index.rst @@ -1,6 +1,18 @@ python-constraint ================= +The Python constraint module offers efficient solvers for Constraint Satisfaction Problems (CSPs) over finite domains in an accessible package. + +>>> from constraint import * +>>> problem = Problem() +>>> problem.addVariable("a", [1,2]) +>>> problem.addVariable("b", [3,4]) +>>> problem.getSolutions() +[{'a': 2, 'b': 4}, {'a': 2, 'b': 3}, {'a': 1, 'b': 4}, {'a': 1, 'b': 3}] +>>> problem.addConstraint(lambda a, b: a*2 == b, ("a", "b")) +>>> problem.getSolutions() +[{'a': 2, 'b': 4}] + .. toctree:: :maxdepth: 2 :caption: Contents: diff --git a/documentation/source/intro.rst b/documentation/source/intro.rst index 099e5af..2bf94a0 100644 --- a/documentation/source/intro.rst +++ b/documentation/source/intro.rst @@ -4,7 +4,8 @@ python-constraint Introduction ------------ -The Python constraint module offers solvers for `Constraint Satisfaction Problems (CSPs) `_ over finite domains in simple and pure Python. CSP is class of problems which may be represented in terms of variables (a, b, ...), domains (a in [1, 2, 3], ...), and constraints (a < b, ...). +The Python constraint module offers efficient solvers for `Constraint Satisfaction Problems (CSPs) `_ over finite domains in an accessible Python package. +CSP is class of problems which may be represented in terms of variables (a, b, ...), domains (a in [1, 2, 3], ...), and constraints (a < b, ...). Examples -------- @@ -12,7 +13,7 @@ Examples Basics ~~~~~~ -This interactive Python session demonstrates the module basic operation: +This interactive Python session demonstrates basic operations: .. code-block:: python @@ -96,6 +97,7 @@ Features The following solvers are available: - Backtracking solver +- Optimized backtracking solver - Recursive backtracking solver - Minimum conflicts solver @@ -108,15 +110,17 @@ Predefined constraint types currently available: - :any:`FunctionConstraint` - :any:`AllDifferentConstraint` - :any:`AllEqualConstraint` -- :any:`ExactSumConstraint` - :any:`MaxSumConstraint` +- :any:`ExactSumConstraint` - :any:`MinSumConstraint` +- :any:`MaxProdConstraint` +- :any:`MinProdConstraint` - :any:`InSetConstraint` - :any:`NotInSetConstraint` - :any:`SomeInSetConstraint` - :any:`SomeNotInSetConstraint` - + Download and install -------------------- diff --git a/documentation/source/reference.rst b/documentation/source/reference.rst index bcb950f..5985220 100644 --- a/documentation/source/reference.rst +++ b/documentation/source/reference.rst @@ -25,6 +25,10 @@ Solvers :members: :member-order: bysource +.. autoclass:: constraint.OptimizedBacktrackingSolver + :members: + :member-order: bysource + .. autoclass:: constraint.RecursiveBacktrackingSolver :members: :member-order: bysource @@ -65,6 +69,14 @@ Constraints :members: :member-order: bysource +.. autoclass:: constraint.MaxProdConstraint + :members: + :member-order: bysource + +.. autoclass:: constraint.MinProdConstraint + :members: + :member-order: bysource + .. autoclass:: constraint.InSetConstraint :members: :member-order: bysource diff --git a/noxfile.py b/noxfile.py new file mode 100644 index 0000000..b8a55fd --- /dev/null +++ b/noxfile.py @@ -0,0 +1,28 @@ +"""Configuration file for the Nox test runner. + +This instantiates the specified sessions in isolated environments and runs the tests. +This allows for locally mirroring the testing occuring with GitHub-actions. +Be careful that the general setup of tests is left to pyproject.toml. +""" + + +import nox + +nox.options.stop_on_first_error = True +nox.options.error_on_missing_interpreters = True + +# Test code quality: linting +@nox.session +def lint(session: nox.Session) -> None: + """Ensure the code is formatted as expected.""" + session.install("ruff") + session.run("ruff", "--format=github", "--config=pyproject.toml", ".") + +# Test code compatiblity and coverage +# @nox.session # uncomment this line to only run on the current python interpreter +@nox.session(python=["3.8", "3.9", "3.10", "3.11"]) # missing versions can be installed with `pyenv install ...` +# do not forget check / set the versions with `pyenv global`, or `pyenv local` in case of virtual environment +def tests(session: nox.Session) -> None: + """Run the tests for the specified Python versions.""" + session.install(".[test]") + session.run("pytest") diff --git a/pyproject.toml b/pyproject.toml new file mode 100644 index 0000000..7c38f8f --- /dev/null +++ b/pyproject.toml @@ -0,0 +1,90 @@ +[build-system] +requires = ["setuptools", "wheel", "Cython>=3.0.0"] + +[project] +description = "python-constraint is a module implementing support for handling CSPs (Constraint Solving Problems) over finite domain" +name = "python-constraint" +version = "2.0.0" +requires-python = ">=3.8" +license = { file = "LICENSE" } +authors = [ + { name = "Gustavo Niemeyer", email = "gustavo@niemeyer.net" }, + { name = "Sébastien Celles", email = "s.celles@gmail.com" }, + { name = "Floris-Jan Willemsen", email = "fjwillemsen97@gmail.com" }, +] +keywords = [ + "CSP", + "constraint solving problems", + "problem solver", + "SMT", + "satisfiability modulo theory", + "SAT", +] +classifiers = [ + "Environment :: Console", + "Intended Audience :: Developers", + "Intended Audience :: Science/Research", + "Intended Audience :: Education", + "Natural Language :: English", + "Programming Language :: Python :: 3.8", + "Programming Language :: Python :: 3.9", + "Programming Language :: Python :: 3.10", + "Programming Language :: Python :: 3.11", + "Topic :: Scientific/Engineering", + "Topic :: Software Development", + "Development Status :: 5 - Production/Stable", +] + +[project.readme] +file = "README.rst" +content-type = "text/x-rst" + +[project.urls] +Homepage = "https://labix.org/python-constraint" +Documentation = "http://labix.org/doc/constraint/" +Source = "https://github.com/python-constraint/python-constraint" +Tracker = "https://github.com/python-constraint/python-constraint/issues" + +[project.optional-dependencies] +doc = [ + "sphinx >= 7.2.4", + "sphinx-autodoc-typehints >= 1.24.0", + "sphinx_rtd_theme >= 1.3.0", + "toml >= 0.10.2", +] +test = [ + "pytest >= 7.4.0", + "pytest-cov >= 4.1.0", + "nox >= 2023.4.22", + "ruff >= 0.0.286", + "pep440 >= 0.1.2", + "tomli >= 2.0.1", # can be replaced by built-in [tomllib](https://docs.python.org/3.11/library/tomllib.html) from Python 3.11 +] + +[tool.setuptools] +py-modules = ["setup_cythonize"] +[tool.setuptools.cmdclass] +build_py = "setup_cythonize.build_py" + +[tool.black] +line-length = 120 +[tool.ruff] +line-length = 120 +src = ["constraint"] +respect-gitignore = true +exclude = ["documentation", "examples", "tests"] +select = [ + "E", # pycodestyle + "F", # pyflakes, + "D", # pydocstyle, +] +[tool.ruff.pydocstyle] +convention = "google" + +[tool.pytest.ini_options] +minversion = "7.3" +pythonpath = [ + "constraint", +] # necessary to get coverage reports without installing with `-e` +addopts = "--cov --cov-config=.coveragerc --cov-report html --cov-report term-missing --cov-fail-under 80" +testpaths = ["tests"] diff --git a/setup.cfg b/setup.cfg deleted file mode 100644 index 5bf0452..0000000 --- a/setup.cfg +++ /dev/null @@ -1,9 +0,0 @@ -[bdist_wheel] -universal = 1 - -[bdist_rpm] -doc_files = README.rst -use_bzip2 = 1 - -[sdist] -formats = bztar diff --git a/setup.py b/setup.py deleted file mode 100755 index 77b0253..0000000 --- a/setup.py +++ /dev/null @@ -1,102 +0,0 @@ -#!/usr/bin/env python -# -*- coding: utf-8 -*- - -from setuptools import setup, find_packages # Always prefer setuptools over distutils -from codecs import open # To use a consistent encoding -from os import path -import io - -NAME = "python-constraint" -filename = "%s/version.py" % "constraint" -with open(filename) as f: - exec(f.read()) - -here = path.abspath(path.dirname(__file__)) - - -def readme(): - filename = path.join(here, "README.rst") - with io.open(filename, "rt", encoding="UTF-8") as f: - return f.read() - - -setup( - name=NAME, - # Versions should comply with PEP440. For a discussion on single-sourcing - # the version across setup.py and the project code, see - # https://packaging.python.org/en/latest/development.html#single-sourcing-the-version - # version='0.0.1', - version=__version__, - description="python-constraint is a module implementing support " - "for handling CSPs (Constraint Solving Problems) over finite domain", - long_description=readme(), - # The project's main homepage. - url=__url__, - # Author details - author=__author__, - author_email=__email__, - # Choose your license - license=__license__, - # See https://pypi.python.org/pypi?%3Aaction=list_classifiers - classifiers=[ - # How mature is this project? Common values are - # 3 - Alpha - # 4 - Beta - # 5 - Production/Stable - "Development Status :: 3 - Alpha", - # Indicate who your project is intended for - "Environment :: Console", - # 'Topic :: Software Development :: Build Tools', - "Intended Audience :: Science/Research", - "Operating System :: OS Independent", - # Specify the Python versions you support here. In particular, ensure - # that you indicate whether you support Python 2, Python 3 or both. - "Programming Language :: Cython", - "Programming Language :: Python", - # 'Programming Language :: Python :: 2', - # 'Programming Language :: Python :: 2.6', - "Programming Language :: Python :: 2.7", - # 'Programming Language :: Python :: 3', - # 'Programming Language :: Python :: 3.2', - # "Programming Language :: Python :: 3.3", - "Programming Language :: Python :: 3.4", - "Programming Language :: Python :: 3.5", - "Programming Language :: Python :: 3.6", - "Topic :: Scientific/Engineering", - # Pick your license as you wish (should match "license" above) - "License :: OSI Approved :: BSD License", - ], - # What does your project relate to? - keywords="csp constraint solving problems problem solver", - # You can just specify the packages manually here if your project is - # simple. Or you can use find_packages(). - packages=find_packages(exclude=["contrib", "docs", "tests*"]), - # List run-time dependencies here. These will be installed by pip when your - # project is installed. For an analysis of "install_requires" vs pip's - # requirements files see: - # https://packaging.python.org/en/latest/technical.html#install-requires-vs-requirements-files - install_requires=[], - # List additional groups of dependencies here (e.g. development dependencies). - # You can install these using the following syntax, for example: - # $ pip install -e .[dev,test] - extras_require={"dev": ["check-manifest", "nose"], "test": ["coverage", "nose"]}, - # If there are data files included in your packages that need to be - # installed, specify them here. If using Python 2.6 or less, then these - # have to be included in MANIFEST.in as well. - # package_data={ - # 'sample': ['logging.conf'], - # }, - # Although 'package_data' is the preferred approach, in some case you may - # need to place data files outside of your packages. - # see http://docs.python.org/3.4/distutils/setupscript.html#installing-additional-files - # In this case, 'data_file' will be installed into '/my_data' - # data_files=[('my_data', ['data/data_file'])], - # To provide executable scripts, use entry points in preference to the - # "scripts" keyword. Entry points provide cross-platform support and allow - # pip to create the appropriate form of executable for the target platform. - # entry_points={ - # 'console_scripts': [ - # 'sample=sample:main', - # ], - # }, -) diff --git a/setup_cythonize.py b/setup_cythonize.py new file mode 100644 index 0000000..54e3225 --- /dev/null +++ b/setup_cythonize.py @@ -0,0 +1,18 @@ +"""Cythonize the listed modules to C-extensions.""" + +from setuptools import Extension +from setuptools.command.build_py import build_py as _build_py + +class build_py(_build_py): + """Used by `tool.setuptools` in pyproject.toml to Cythonize.""" + + def run(self): # noqa: D102 + self.run_command("build_ext") + return super().run() + + def initialize_options(self): # noqa: D102 + super().initialize_options() + cython_modules = ['constraints', 'domain', 'problem', 'solvers'] + ext = "py" + extensions = [Extension(f"constraint.{module}", [f"constraint/{module}.{ext}"]) for module in cython_modules] + self.distribution.ext_modules = extensions diff --git a/tests/__init__.py b/tests/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/tests/test_constraint.py b/tests/test_constraint.py index d4ef738..a9303c9 100644 --- a/tests/test_constraint.py +++ b/tests/test_constraint.py @@ -13,8 +13,6 @@ # from examples.wordmath import (seisseisdoze, sendmoremoney, twotwofour) # from examples.xsum import xsum -import constraint.compat as compat - def test_abc(): solutions = abc.solve() @@ -109,6 +107,22 @@ def test_constraint_without_variables(): solutions = problem.getSolutions() assert solutions == [{"a": 3}] +def test_multipliers(): + """Test the multiplier functionality in the constraints.""" + from constraint import MaxSumConstraint, ExactSumConstraint, MinSumConstraint + problem = constraint.Problem() + problem.addVariable("x", [-1, 0, 1, 2]) + problem.addVariable("y", [1, 2]) + problem.addConstraint(MaxSumConstraint(4, [2, 1]), ["x", "y"]) + problem.addConstraint(ExactSumConstraint(4, [1, 2]), ["x", "y"]) + problem.addConstraint(MinSumConstraint(0, [0.5, 1]), ["x"]) + + possible_solutions = [ + {'y': 2, 'x': 0}, + {'y': 1, 'x': 2} + ] -def test_version(): - assert isinstance(constraint.__version__, compat.string_types) + # get the solutions + solutions = problem.getSolutions() + for solution in solutions: + assert solution in possible_solutions diff --git a/tests/test_doctests.py b/tests/test_doctests.py new file mode 100644 index 0000000..6e29711 --- /dev/null +++ b/tests/test_doctests.py @@ -0,0 +1,10 @@ +import doctest +import constraint.problem as problem +import constraint.domain as domain +import constraint.constraints as constraints +import constraint.solvers as solvers + +assert doctest.testmod(problem)[0] == 0 +assert doctest.testmod(domain)[0] == 0 +assert doctest.testmod(constraints, extraglobs={'Problem': problem.Problem})[0] == 0 +assert doctest.testmod(solvers, extraglobs={'Problem': problem.Problem})[0] == 0 diff --git a/tests/test_solvers.py b/tests/test_solvers.py index 3fac3c9..945bd4f 100644 --- a/tests/test_solvers.py +++ b/tests/test_solvers.py @@ -1,11 +1,65 @@ -from constraint import Problem, MinConflictsSolver +from constraint import Problem, MinConflictsSolver, BacktrackingSolver, OptimizedBacktrackingSolver, RecursiveBacktrackingSolver, MaxProdConstraint, MinProdConstraint, MinSumConstraint, FunctionConstraint def test_min_conflicts_solver(): problem = Problem(MinConflictsSolver()) problem.addVariable("x", [0, 1]) problem.addVariable("y", [0, 1]) + + possible_solutions = [ + {"x": 0, "y": 0}, + {"x": 0, "y": 1}, + {"x": 1, "y": 0}, + {"x": 1, "y": 1}, + ] + + # test if all solutions are eventually found by iteration and adding the last solutions as a constraint + for _ in possible_solutions: + solution = problem.getSolution() + assert solution in possible_solutions + problem.addConstraint(FunctionConstraint(lambda x, y: (lambda x, y, xs, ys: x != xs or y != ys)(x, y, solution['x'], solution['y']))) + +def test_optimized_backtracking_solver(): + # setup the solvers + problem_bt = Problem(BacktrackingSolver()) + problem_opt = Problem(OptimizedBacktrackingSolver()) + problem_opt_nfwd = Problem(OptimizedBacktrackingSolver(forwardcheck=False)) + problems = [problem_bt, problem_opt, problem_opt_nfwd] + + # define the problem for all solvers + for problem in problems: + problem.addVariable("x", [-1, 0, 1, 2]) + problem.addVariable("y", [1, 2]) + problem.addConstraint(MaxProdConstraint(2), ["x", "y"]) + problem.addConstraint(MinProdConstraint(1), ["x", "y"]) + problem.addConstraint(MinSumConstraint(0), ["x"]) + + # get the solutions + true_solutions = [(2, 1), (1, 2), (1, 1)] + order = ["x", "y"] + solution = problem_bt.getSolution() + solution_tuple = tuple(solution[key] for key in order) + + # validate a single solution + solution_opt = problem_opt.getSolution() + assert tuple(solution_opt[key] for key in order) in true_solutions + + # validate all solutions + def validate(solutions_list, solutions_dict, size): + assert size == len(true_solutions) + assert solution_tuple in solutions_list + assert solution_tuple in solutions_dict + assert all(sol in solutions_list for sol in true_solutions) + + validate(*problem_opt.getSolutionsAsListDict(order=order)) + validate(*problem_opt_nfwd.getSolutionsAsListDict(order=order)) + +def test_recursive_backtracking_solver(): + problem = Problem(RecursiveBacktrackingSolver()) + problem.addVariable("x", [0, 1]) + problem.addVariable("y", [0, 1]) solution = problem.getSolution() + solutions = problem.getSolutions() possible_solutions = [ {"x": 0, "y": 0}, @@ -15,3 +69,4 @@ def test_min_conflicts_solver(): ] assert solution in possible_solutions + assert all(sol in possible_solutions for sol in solutions) diff --git a/tests/test_toml_file.py b/tests/test_toml_file.py new file mode 100644 index 0000000..b248e7c --- /dev/null +++ b/tests/test_toml_file.py @@ -0,0 +1,64 @@ +"""Tests for release information.""" + +from pathlib import Path + +import tomli + +package_root = Path('.').parent.parent +pyproject_toml_path = package_root / "pyproject.toml" +assert pyproject_toml_path.exists() +with open(pyproject_toml_path, mode="rb") as fp: + pyproject = tomli.load(fp) + + +def test_read(): + """Test whether the contents have been read correctly and the required keys are in place.""" + assert isinstance(pyproject, dict) + assert "build-system" in pyproject + assert "project" in pyproject + + +def test_name(): + """Ensure the name is consistent.""" + assert "name" in pyproject["project"] + assert pyproject["project"]["name"] == "python-constraint" + + +def test_versioning(): + """Test whether the versioning is PEP440 compliant.""" + from pep440 import is_canonical + + assert "version" in pyproject["project"] + assert is_canonical(pyproject["project"]["version"]) + + +def test_authors(): + """Ensure the authors are specified.""" + assert "authors" in pyproject["project"] + assert len(pyproject["project"]["authors"]) > 0 + + +def test_license(): + """Ensure the license is set and the file exists.""" + assert "license" in pyproject["project"] + license = pyproject["project"]["license"] + assert len(license) > 0 + assert license["file"] == "LICENSE" + assert Path(package_root / "LICENSE").exists() + + +def test_readme(): + """Ensure the readme is set and the file exists.""" + assert "readme" in pyproject["project"] + readme = pyproject["project"]["readme"]["file"] + assert len(readme) > 0 + assert Path(package_root / readme).exists() + + +def test_project_keys(): + """Check whether the expected keys in [project] are present.""" + project = pyproject["project"] + assert "description" in project + assert "keywords" in project + assert "classifiers" in project + assert "requires-python" in project