diff --git a/tests/test_constraint_streams.py b/tests/test_constraint_streams.py
index bf973c1..488de86 100644
--- a/tests/test_constraint_streams.py
+++ b/tests/test_constraint_streams.py
@@ -600,6 +600,7 @@ def define_constraints(constraint_factory: ConstraintFactory):
'ifNotExistsIncludingNullVars',
'ifExistsOtherIncludingNullVars',
'ifNotExistsOtherIncludingNullVars',
+ 'toCollection',
}
diff --git a/timefold-solver-python-core/src/main/python/__init__.py b/timefold-solver-python-core/src/main/python/__init__.py
index 619b6c1..6219f15 100644
--- a/timefold-solver-python-core/src/main/python/__init__.py
+++ b/timefold-solver-python-core/src/main/python/__init__.py
@@ -1,11 +1,44 @@
"""
-This module wraps Timefold and allow Python Objects
-to be used as the domain and Python functions to be used
-as the constraints.
+`Timefold Solver `_ is a lightweight,
+embeddable constraint satisfaction engine which optimizes planning problems.
-Using any decorators in this module will automatically start
-the JVM. If you want to pass custom arguments to the JVM,
-use init before decorators and any timefold.solver.types imports.
+It solves use cases such as:
+
+ - Employee shift rostering: timetabling nurses, repairmen, ...
+
+ - Vehicle routing: planning vehicle routes for moving freight and/or passengers through
+ multiple destinations using known mapping tools ...
+
+ - Agenda scheduling: scheduling meetings, appointments, maintenance jobs, advertisements, ...
+
+
+Planning problems are defined using Python classes and functions.
+
+Examples
+--------
+>>> from timefold.solver import Solver, SolverFactory
+>>> from timefold.solver.config import (SolverConfig, ScoreDirectorFactoryConfig,
+... TerminationConfig, Duration)
+>>> from domain import Timetable, Lesson, generate_problem
+>>> from constraints import my_constraints
+...
+>>> solver_config = SolverConfig(solution_class=Timetable, entity_class_list=[Lesson],
+... score_director_factory_config=ScoreDirectorFactoryConfig(
+... constraint_provider_function=my_constraints
+... ),
+... termination_config=TerminationConfig(
+... spent_limit=Duration(seconds=30))
+... )
+>>> solver = SolverFactory.create(solver_config).build_solver()
+>>> problem = generate_problem()
+>>> solution = solver.solve(problem)
+
+See Also
+--------
+:mod:`timefold.solver.config`
+:mod:`timefold.solver.domain`
+:mod:`timefold.solver.score`
+:mod:`timefold.solver.test`
"""
from ._problem_change import *
from ._solution_manager import *
diff --git a/timefold-solver-python-core/src/main/python/_future.py b/timefold-solver-python-core/src/main/python/_future.py
index 34c6639..16bc243 100644
--- a/timefold-solver-python-core/src/main/python/_future.py
+++ b/timefold-solver-python-core/src/main/python/_future.py
@@ -1,42 +1,30 @@
-from ._jpype_type_conversions import PythonBiFunction
from typing import Awaitable, TypeVar, TYPE_CHECKING
-from asyncio import Future, get_event_loop, CancelledError
if TYPE_CHECKING:
- from java.util.concurrent import (Future as JavaFuture,
- CompletableFuture as JavaCompletableFuture)
+ from java.util.concurrent import Future as JavaFuture
Result = TypeVar('Result')
-def wrap_future(future: 'JavaFuture[Result]') -> Awaitable[Result]:
- async def get_result() -> Result:
- nonlocal future
- return future.get()
+class JavaFutureAwaitable(Awaitable[Result]):
+ _future: 'JavaFuture[Result]'
- return get_result()
+ def __init__(self, future: 'JavaFuture[Result]') -> None:
+ self._future = future
+ def __await__(self) -> Result:
+ return self
-def wrap_completable_future(future: 'JavaCompletableFuture[Result]') -> Future[Result]:
- loop = get_event_loop()
- out = loop.create_future()
+ def __iter__(self):
+ return self
- def result_handler(result, error):
- nonlocal out
- if error is not None:
- out.set_exception(error)
- else:
- out.set_result(result)
+ def __next__(self):
+ raise StopIteration(self._future.get())
- def cancel_handler(python_future: Future):
- nonlocal future
- if isinstance(python_future.exception(), CancelledError):
- future.cancel(True)
- future.handle(PythonBiFunction(result_handler))
- out.add_done_callback(cancel_handler)
- return out
+def wrap_future(future: 'JavaFuture[Result]') -> Awaitable[Result]:
+ return JavaFutureAwaitable(future)
-__all__ = ['wrap_future', 'wrap_completable_future']
+__all__ = ['wrap_future']
diff --git a/timefold-solver-python-core/src/main/python/_problem_change.py b/timefold-solver-python-core/src/main/python/_problem_change.py
index e880121..cc6e02e 100644
--- a/timefold-solver-python-core/src/main/python/_problem_change.py
+++ b/timefold-solver-python-core/src/main/python/_problem_change.py
@@ -14,6 +14,15 @@
class ProblemChangeDirector:
+ """
+ Allows external changes to the working solution.
+ If the changes are not applied through the `ProblemChangeDirector`,
+ both internal and custom variable listeners are never notified about them,
+ resulting to inconsistencies in the working solution.
+ Should be used only from a `ProblemChange` implementation.
+
+ To see an example implementation, please refer to the `ProblemChange` docstring.
+ """
_delegate: '_ProblemChangeDirector'
_java_solution: Solution_
_python_solution: Solution_
@@ -38,6 +47,16 @@ def _replace_solution_in_callable(self, callable: Callable):
return callable
def add_entity(self, entity: Entity, modifier: Callable[[Entity], None]) -> None:
+ """
+ Add a new ``planning_entity`` instance into the ``working solution``.
+
+ Parameters
+ ----------
+ entity : Entity
+ The ``planning_entity`` instance
+ modifier : Callable[[Entity], None]
+ A callable that adds the entity to the working solution.
+ """
from java.util.function import Consumer
converted_modifier = translate_python_bytecode_to_java_bytecode(self._replace_solution_in_callable(modifier),
Consumer)
@@ -45,6 +64,16 @@ def add_entity(self, entity: Entity, modifier: Callable[[Entity], None]) -> None
update_python_object_from_java(self._java_solution)
def add_problem_fact(self, fact: ProblemFact, modifier: Callable[[ProblemFact], None]) -> None:
+ """
+ Add a new problem fact instance into the ``working solution``.
+
+ Parameters
+ ----------
+ fact : ProblemFact
+ The problem fact instance
+ modifier : Callable[[ProblemFact], None]
+ A callable that adds the fact to the working solution.
+ """
from java.util.function import Consumer
converted_modifier = translate_python_bytecode_to_java_bytecode(self._replace_solution_in_callable(modifier),
Consumer)
@@ -53,6 +82,18 @@ def add_problem_fact(self, fact: ProblemFact, modifier: Callable[[ProblemFact],
def change_problem_property(self, problem_fact_or_entity: EntityOrProblemFact,
modifier: Callable[[EntityOrProblemFact], None]) -> None:
+ """
+ Change a property of either a ``planning_entity`` or a problem fact.
+ Translates the entity or the problem fact to its working solution counterpart
+ by performing a lookup as defined by `lookup_working_object_or_fail`.
+
+ Parameters
+ ----------
+ problem_fact_or_entity : EntityOrProblemFact
+ The ``planning_entity`` or problem fact instance
+ modifier : Callable[[EntityOrProblemFact], None]
+ Updates the property of the ``planning_entity`` or the problem fact
+ """
from java.util.function import Consumer
converted_modifier = translate_python_bytecode_to_java_bytecode(self._replace_solution_in_callable(modifier),
Consumer)
@@ -62,6 +103,20 @@ def change_problem_property(self, problem_fact_or_entity: EntityOrProblemFact,
def change_variable(self, entity: Entity, variable: str,
modifier: Callable[[Entity], None]) -> None:
+ """
+ Change a ``PlanningVariable`` value of a ``planning_entity``.
+ Translates the entity to a working planning entity
+ by performing a lookup as defined by `lookup_working_object_or_fail`.
+
+ Parameters
+ ----------
+ entity : Entity
+ The ``planning_entity`` instance
+ variable : str
+ Name of the ``PlanningVariable``
+ modifier : Callable[[Entity], None]
+ Updates the value of the ``PlanningVariable`` inside the ``planning_entity``
+ """
from java.util.function import Consumer
converted_modifier = translate_python_bytecode_to_java_bytecode(self._replace_solution_in_callable(modifier),
Consumer)
@@ -69,15 +124,65 @@ def change_variable(self, entity: Entity, variable: str,
update_python_object_from_java(self._java_solution)
def lookup_working_object(self, external_object: EntityOrProblemFact) -> Optional[EntityOrProblemFact]:
+ """
+ As defined by `lookup_working_object_or_fail`,
+ but doesn't fail fast if no working object was ever added for the `external_object`.
+ It's recommended to use `lookup_working_object_or_fail` instead.
+
+ Parameters
+ ----------
+ external_object : EntityOrProblemFact
+ The entity or fact instance to lookup.
+ Can be ``None``.
+
+ Returns
+ -------
+ EntityOrProblemFact | None
+ None if there is no working object for the `external_object`, the looked up object
+ otherwise.
+
+ Raises
+ ------
+ If it cannot be looked up or if the `external_object`'s class is not supported.
+ """
out = self._delegate.lookUpWorkingObject(convert_to_java_python_like_object(external_object)).orElse(None)
if out is None:
return None
return unwrap_python_like_object(out)
def lookup_working_object_or_fail(self, external_object: EntityOrProblemFact) -> EntityOrProblemFact:
+ """
+ Translate an entity or fact instance (often from another Thread )
+ to this `ProblemChangeDirector`'s internal working instance.
+
+ Matches entities by ``PlanningId`` by default.
+
+ Parameters
+ ----------
+ external_object : EntityOrProblemFact
+ The entity or fact instance to lookup.
+ Can be ``None``.
+
+ Raises
+ ------
+ If there is no working object for `external_object`,
+ if it cannot be looked up or if the `external_object`'s class is not supported.
+ """
return unwrap_python_like_object(self._delegate.lookUpWorkingObjectOrFail(external_object))
def remove_entity(self, entity: Entity, modifier: Callable[[Entity], None]) -> None:
+ """
+ Remove an existing `planning_entity` instance from the ``working solution``.
+ Translates the entity to its working solution counterpart
+ by performing a lookup as defined by `lookup_working_object_or_fail`.
+
+ Parameters
+ ----------
+ entity : Entity
+ The ``planning_entity`` instance
+ modifier : Callable[[Entity], None]
+ Removes the working entity from the ``working solution``.
+ """
from java.util.function import Consumer
converted_modifier = translate_python_bytecode_to_java_bytecode(self._replace_solution_in_callable(modifier),
Consumer)
@@ -85,6 +190,18 @@ def remove_entity(self, entity: Entity, modifier: Callable[[Entity], None]) -> N
update_python_object_from_java(self._java_solution)
def remove_problem_fact(self, fact: ProblemFact, modifier: Callable[[ProblemFact], None]) -> None:
+ """
+ Remove an existing problem fact instance from the ``working solution``.
+ Translates the problem fact to its working solution counterpart
+ by performing a lookup as defined by `lookup_working_object_or_fail`.
+
+ Parameters
+ ----------
+ fact : ProblemFact
+ The problem fact instance
+ modifier : Callable[[ProblemFact], None]
+ Removes the working problem fact from the ``working solution``.
+ """
from java.util.function import Consumer
converted_modifier = translate_python_bytecode_to_java_bytecode(self._replace_solution_in_callable(modifier),
Consumer)
@@ -92,13 +209,71 @@ def remove_problem_fact(self, fact: ProblemFact, modifier: Callable[[ProblemFact
update_python_object_from_java(self._java_solution)
def update_shadow_variables(self) -> None:
+ """
+ Calls variable listeners on the external changes submitted so far.
+ This happens automatically after the entire `ProblemChange` has been processed,
+ but this method allows the user to specifically request it in the middle of the `ProblemChange`.
+ """
self._delegate.updateShadowVariables()
update_python_object_from_java(self._java_solution)
class ProblemChange(Generic[Solution_], ABC):
+ """
+ A `ProblemChange` represents a change in one or more planning entities or problem facts of a `planning_solution`.
+
+ The Solver checks the presence of waiting problem changes after every Move evaluation.
+ If there are waiting problem changes, the Solver:
+
+ 1. clones the last best solution and sets the clone as the new working solution
+ 2. applies every problem change keeping the order in which problem changes have been submitted; after every problem change, variable listeners are triggered
+ 3. calculates the score and makes the updated working solution the new best solution; note that this solution is not published via the ai. timefold. solver. core. api. solver. event. BestSolutionChangedEvent, as it hasn't been initialized yet
+ 4. restarts solving to fill potential uninitialized planning entities
+
+ Note that the Solver clones a `planning_solution` at will.
+ Any change must be done on the problem facts and planning entities referenced by the `planning_solution`.
+
+ Examples
+ --------
+ An example implementation, based on the Cloud balancing problem, looks as follows:
+ >>> from timefold.solver import ProblemChange
+ >>> from domain import CloudBalance, CloudComputer
+ >>>
+ >>> class DeleteComputerProblemChange(ProblemChange[CloudBalance]):
+ ... computer: CloudComputer
+ ...
+ ... def __init__(self, computer: CloudComputer):
+ ... self.computer = computer
+ ...
+ ... def do_change(self, cloud_balance: CloudBalance, problem_change_director: ProblemChangeDirector):
+ ... working_computer = problem_change_director.lookup_working_object_or_fail(self.computer)
+ ... # First remove the problem fact from all planning entities that use it
+ ... for process in cloud_balance.process_list:
+ ... if process.computer == working_computer:
+ ... problem_change_director.change_variable(process, "computer",
+ ... lambda working_process: setattr(working_process,
+ ... 'computer', None))
+ ... # A SolutionCloner does not clone problem fact lists (such as computer_list), only entity lists.
+ ... # Shallow clone the computer_list so only the working solution is affected.
+ ... computer_list = cloud_balance.computer_list.copy()
+ ... cloud_balance.computer_list = computer_list
+ ... # Remove the problem fact itself
+ ... problem_change_director.remove_problem_fact(working_computer, computer_list.remove)
+ """
@abstractmethod
def do_change(self, working_solution: Solution_, problem_change_director: ProblemChangeDirector) -> None:
+ """
+ Do the change on the `planning_solution`.
+ Every modification to the `planning_solution` must be done via the `ProblemChangeDirector`,
+ otherwise the Score calculation will be corrupted.
+
+ Parameters
+ ----------
+ working_solution : Solution_
+ the working solution which contains the problem facts (and planning entities) to change
+ problem_change_director : ProblemChangeDirector
+ `ProblemChangeDirector` to perform the change through
+ """
...
diff --git a/timefold-solver-python-core/src/main/python/_solution_manager.py b/timefold-solver-python-core/src/main/python/_solution_manager.py
index 0e781da..d8e2dd0 100644
--- a/timefold-solver-python-core/src/main/python/_solution_manager.py
+++ b/timefold-solver-python-core/src/main/python/_solution_manager.py
@@ -16,6 +16,10 @@
class SolutionManager(Generic[Solution_]):
+ """
+ A stateless service to help calculate `Score`, `ConstraintMatchTotal`, `Indictment`, etc.
+ To create a `SolutionManager` instance, use `create`.
+ """
_delegate: '_JavaSolutionManager'
def __init__(self, delegate: '_JavaSolutionManager'):
@@ -24,10 +28,36 @@ def __init__(self, delegate: '_JavaSolutionManager'):
@staticmethod
def create(solver_factory: SolverFactory[Solution_] | SolverManager[Solution_, Any]) -> \
'SolutionManager[Solution_]':
+ """
+ Uses a `SolverFactory` or `SolverManager` to build a SolutionManager.
+
+ Parameters
+ ----------
+ solver_factory : SolverFactory | SolverManager
+
+ Returns
+ -------
+ SolutionManager
+ A `SolutionManager` instance.
+ """
from ai.timefold.solver.core.api.solver import SolutionManager as JavaSolutionManager
return SolutionManager(JavaSolutionManager.create(solver_factory._delegate))
def update(self, solution: Solution_, solution_update_policy=None) -> 'Score':
+ """
+ Updates the given solution according to the `SolutionUpdatePolicy`.
+
+ Parameters
+ ----------
+ solution : Solution_
+ The solution to explain
+ solution_update_policy
+
+ Returns
+ -------
+ Score
+ The score of the updated solution.
+ """
# TODO handle solution_update_policy
from jpyinterpreter import convert_to_java_python_like_object, update_python_object_from_java
java_solution = convert_to_java_python_like_object(solution)
@@ -37,11 +67,42 @@ def update(self, solution: Solution_, solution_update_policy=None) -> 'Score':
def analyze(self, solution: Solution_, score_analysis_fetch_policy=None, solution_update_policy=None) \
-> 'ScoreAnalysis':
+ """
+ Calculates and retrieves information about which constraints contributed to the solution's score.
+ This is a faster, JSON-friendly version of `explain`.
+
+ Parameters
+ ----------
+ solution : Solution_
+ A fully initialized solution
+ score_analysis_fetch_policy
+ solution_update_policy
+
+ Returns
+ -------
+ ScoreAnalysis
+ The `ScoreAnalysis` corresponding to the given solution.
+ """
# TODO handle policies
from jpyinterpreter import convert_to_java_python_like_object
return ScoreAnalysis(self._delegate.analyze(convert_to_java_python_like_object(solution)))
def explain(self, solution: Solution_, solution_update_policy=None) -> 'ScoreExplanation':
+ """
+ Calculates and retrieves ConstraintMatchTotals and Indictments necessary for
+ describing the quality of a particular solution.
+ For a simplified, faster and JSON-friendly alternative, see `analyze`.
+
+ Parameters
+ ----------
+ solution
+ solution_update_policy
+
+ Returns
+ -------
+ ScoreExplanation
+ The `ScoreExplanation` corresponding to the given solution.
+ """
# TODO handle policies
from jpyinterpreter import convert_to_java_python_like_object
return ScoreExplanation(self._delegate.explain(convert_to_java_python_like_object(solution)))
diff --git a/timefold-solver-python-core/src/main/python/_solver.py b/timefold-solver-python-core/src/main/python/_solver.py
index 2ff7bb9..03fbaa1 100644
--- a/timefold-solver-python-core/src/main/python/_solver.py
+++ b/timefold-solver-python-core/src/main/python/_solver.py
@@ -1,5 +1,5 @@
from ._problem_change import ProblemChange, ProblemChangeWrapper
-from typing import TypeVar, TYPE_CHECKING, Generic, Callable, List
+from typing import TypeVar, TYPE_CHECKING, Generic, Callable
from datetime import timedelta
from jpype import JClass, JImplements, JOverride
from dataclasses import dataclass
@@ -14,6 +14,35 @@
@dataclass
class BestSolutionChangedEvent(Generic[Solution_]):
+ """
+ Delivered when the best solution changes during solving.
+ Delivered in the solver thread (which is the thread that calls `Solver.solve`).
+
+ Attributes
+ ----------
+ new_best_score: Score
+ Returns the Score of the `new_best_solution`.
+ This is useful for generic code,
+ which doesn't know the type of the `planning_solution` to retrieve the Score
+ from `new_best_solution` easily.
+
+ new_best_solution : Solution_
+ Note that:
+
+ - In real-time planning, not all ProblemChanges might be processed:
+ check `is_every_problem_change_processed`.
+
+ - This `planning_solution` might be uninitialized: check `Score.init_score`.
+
+ - This `planning_solution` might be infeasible: check `Score.is_feasible`.
+
+ is_every_problem_change_processed : bool
+ Checks if all scheduled ProblemChanges have been processed.
+ This method is thread-safe.
+
+ time_spent: timedelta
+ The duration between starting solving and finding the current best solution.
+ """
new_best_score: 'Score'
new_best_solution: Solution_
is_every_problem_change_processed: bool
@@ -21,10 +50,23 @@ class BestSolutionChangedEvent(Generic[Solution_]):
class Solver(Generic[Solution_]):
+ """
+ A `Solver` solves a planning problem and returns the best solution found.
+ It's recommended to create a new `Solver` instance for each dataset.
+
+ To create a `Solver`, use `SolverFactory.build_solver`.
+ To solve a planning problem, call `solve`.
+ To solve a planning problem without blocking the current thread, use `SolverManager` instead.
+
+ These methods are not thread-safe and should be called from the same thread,
+ except for the methods that are explicitly marked as thread-safe.
+ Note that despite that solve is not thread-safe for clients of this class,
+ that method is free to do multithreading inside itself.
+ """
_delegate: '_JavaSolver'
_solution_class: JClass
_has_event_listener: bool
- _event_listener_list: List[Callable[[BestSolutionChangedEvent[Solution_]], None]]
+ _event_listener_list: list[Callable[[BestSolutionChangedEvent[Solution_]], None]]
def __init__(self, delegate: '_JavaSolver', solution_class: JClass):
self._delegate = delegate
@@ -33,6 +75,25 @@ def __init__(self, delegate: '_JavaSolver', solution_class: JClass):
self._event_listener_list = []
def solve(self, problem: Solution_):
+ """
+ Solves the planning problem and returns the best solution encountered
+ (which might or might not be optimal, feasible or even initialized).
+
+ It can take seconds, minutes, even hours or days before this method returns,
+ depending on the termination configuration.
+ To terminate a `Solver` early, call `terminate_early`.
+
+ Parameters
+ ----------
+ problem : Solution_
+ A `planning_solution`, usually its planning variables are uninitialized
+
+ Returns
+ -------
+ Solution_
+ The best solution encountered before terminating.
+ It can return the original, uninitialized `planning_solution` with a ``None`` `Score`.
+ """
from java.lang import Exception as JavaException
from ai.timefold.jpyinterpreter.types.errors import PythonBaseException
from jpyinterpreter import convert_to_java_python_like_object, unwrap_python_like_object
@@ -51,24 +112,116 @@ def solve(self, problem: Solution_):
return unwrap_python_like_object(java_solution)
def is_solving(self) -> bool:
+ """
+ This method is thread-safe.
+
+ Returns
+ -------
+ bool
+ ``True`` if the solve method is still running
+ """
return self._delegate.isSolving()
def terminate_early(self) -> bool:
+ """
+ Notifies the solver that it should stop at its earliest convenience.
+ This method returns immediately, but it takes an undetermined time for the `solve` to actually return.
+
+ If the solver is running in daemon mode, this is the only way to terminate it normally.
+
+ This method is thread-safe.
+ It can only be called from a different thread because the original thread is still calling `solve`.
+
+ Returns
+ -------
+ bool
+ ``True`` if successful, ``False`` if was already terminating or terminated
+ """
return self._delegate.terminateEarly()
def is_terminate_early(self) -> bool:
+ """
+ This method is thread-safe.
+
+ Returns
+ -------
+ bool
+ ``True`` if `terminate_early` has been called since the `Solver` started.
+ """
return self._delegate.isTerminateEarly()
def add_problem_change(self, problem_change: ProblemChange[Solution_]) -> None:
+ """
+ Schedules a `ProblemChange` to be processed.
+ As a side effect, this restarts the `Solver`, effectively resetting all Terminations, but not `terminate_early`.
+ This method is thread-safe.
+ Follow specifications of `queue.Queue.put` with by default a maxsize of 0.
+ To learn more about problem change semantics, please refer to the `ProblemChange` docstring.
+
+ Parameters
+ ----------
+ problem_change : ProblemChange
+ A `ProblemChange` to be processed.
+
+ See Also
+ --------
+ add_problem_changes
+ """
self._delegate.addProblemChange(ProblemChangeWrapper(problem_change)) # noqa
- def add_problem_changes(self, problem_changes: List[ProblemChange[Solution_]]) -> None:
+ def add_problem_changes(self, problem_changes: list[ProblemChange[Solution_]]) -> None:
+ """
+ Schedules multiple `ProblemChange`s to be processed.
+ As a side effect, this restarts the `Solver`, effectively resetting all Terminations, but not `terminate_early`.
+ This method is thread-safe.
+ Follow specifications of `queue.Queue.put` with by default a maxsize of 0.
+ To learn more about problem change semantics, please refer to the `ProblemChange` docstring.
+
+ Parameters
+ ----------
+ problem_changes : list[ProblemChange]
+ A list of `ProblemChange`s to be processed.
+
+ See Also
+ --------
+ add_problem_change
+ """
self._delegate.addProblemChanges([ProblemChangeWrapper(problem_change) for problem_change in problem_changes]) # noqa
def is_every_problem_change_processed(self) -> bool:
+ """
+ Checks if all scheduled `ProblemChange`s have been processed.
+ This method is thread-safe.
+
+ Returns
+ -------
+ bool
+ ``True`` if there are no `ProblemChange`s left to do
+ """
return self._delegate.isEveryProblemChangeProcessed()
def add_event_listener(self, event_listener: Callable[[BestSolutionChangedEvent[Solution_]], None]):
+ """
+ Adds a listener to be notified when a new best solution is found.
+
+ Parameters
+ ----------
+ event_listener : Callable[[BestSolutionChangedEvent[Solution]], None]
+ The listener to be notified when a new best solution is found.
+
+ Examples
+ --------
+ >>> from timefold.solver import Solver, BestSolutionChangedEvent
+ >>> from domain import Timetable, build_solver, generate_problem
+ >>>
+ >>> def best_solution_listener(event: BestSolutionChangedEvent[Timetable]) -> None:
+ ... print(event.new_best_score)
+ ...
+ >>> solver = build_solver()
+ >>> solver.add_event_listener(best_solution_listener)
+ >>> timetable = generate_problem()
+ >>> solver.solve(timetable)
+ """
from ai.timefold.solver.core.api.solver.event import SolverEventListener
event_listener_list = self._event_listener_list
if not self._has_event_listener:
@@ -92,7 +245,15 @@ def bestSolutionChanged(self, event):
event_listener_list.append(event_listener)
- def remove_event_listener(self, event_listener):
+ def remove_event_listener(self, event_listener: Callable[[BestSolutionChangedEvent[Solution_]], None]):
+ """
+ Removes a listener added by `add_event_listener`.
+
+ Parameters
+ ----------
+ event_listener : Callable[[BestSolutionChangedEvent[Solution]], None]
+ The listener to be removed
+ """
self._event_listener_list.remove(event_listener)
diff --git a/timefold-solver-python-core/src/main/python/_solver_factory.py b/timefold-solver-python-core/src/main/python/_solver_factory.py
index 8d6e2d5..8a7dac6 100644
--- a/timefold-solver-python-core/src/main/python/_solver_factory.py
+++ b/timefold-solver-python-core/src/main/python/_solver_factory.py
@@ -13,6 +13,14 @@
class SolverFactory(Generic[Solution_]):
+ """
+ Creates `Solver` instances.
+ Most applications only need one `SolverFactory`.
+ To create a `SolverFactory`, create a `SolverConfig` first and then use
+ `create`.
+
+ These methods are thread-safe unless explicitly stated otherwise.
+ """
_delegate: '_JavaSolverFactory'
_solution_class: JClass
@@ -22,12 +30,42 @@ def __init__(self, delegate: '_JavaSolverFactory', solution_class: JClass):
@staticmethod
def create(solver_config: SolverConfig[Solution_]) -> 'SolverFactory[Solution_]':
+ """
+ Uses a `SolverConfig` to build a `SolverFactory`.
+
+ Parameters
+ ----------
+ solver_config : SolverConfig
+ The `SolverConfig` to build the `SolverFactory` from.
+
+ Returns
+ -------
+ SolverFactory
+ A `SolverFactory` instance.
+
+ Notes
+ -----
+ Subsequent changes to the config have no effect on the returned instance.
+ """
from ai.timefold.solver.core.api.solver import SolverFactory as JavaSolverFactory
solver_config = solver_config._to_java_solver_config()
delegate = JavaSolverFactory.create(solver_config) # noqa
return SolverFactory(delegate, solver_config.getSolutionClass()) # noqa
def build_solver(self, solver_config_override: SolverConfigOverride = None) -> Solver[Solution_]:
+ """
+ Creates a new Solver instance.
+
+ Parameters
+ ----------
+ solver_config_override : SolverConfigOverride, optional
+ If present, overrides to apply to the configured `SolverConfig` on the created `Solver`.
+
+ Returns
+ -------
+ Solver
+ A `Solver` instance.
+ """
if solver_config_override is None:
return Solver(self._delegate.buildSolver(), self._solution_class)
else:
diff --git a/timefold-solver-python-core/src/main/python/_solver_manager.py b/timefold-solver-python-core/src/main/python/_solver_manager.py
index 6375b6f..aa50e6f 100644
--- a/timefold-solver-python-core/src/main/python/_solver_manager.py
+++ b/timefold-solver-python-core/src/main/python/_solver_manager.py
@@ -1,10 +1,9 @@
from ._problem_change import ProblemChange, ProblemChangeWrapper
from .config import SolverConfigOverride
from ._solver_factory import SolverFactory
-from ._future import wrap_completable_future
+from ._future import wrap_future
-from asyncio import Future
-from typing import TypeVar, Generic, Callable, TYPE_CHECKING
+from typing import Awaitable, TypeVar, Generic, Callable, TYPE_CHECKING
from datetime import timedelta
from enum import Enum
@@ -19,9 +18,34 @@
class SolverStatus(Enum):
+ """
+ The status of the problem submitted to the SolverManager.
+ Retrieve this status with `SolverManager.get_solver_status` or
+ `SolverJob.get_solver_status`.
+ """
+
NOT_SOLVING = 'NOT_SOLVING'
+ """
+ The problem's solving has terminated or the problem was never submitted to the `SolverManager`.
+ `SolverManager.get_solver_status` cannot tell the difference, but `SolverJob.get_solver_status` can.
+ """
+
SOLVING_SCHEDULED = 'SOLVING_SCHEDULED'
+ """
+ No solver thread started solving this problem yet, but sooner or later a solver thread will solve it.
+ For example, submitting 7 problems to a `SolverManager` with a `SolverManagerConfig.parallel_solver_count` of 4,
+ puts 3 into this state for non-trivial amount of time.
+
+ Transitions into `SOLVING_ACTIVE` (or `NOT_SOLVING` if it is terminated early, before it starts).
+ """
+
SOLVING_ACTIVE = 'SOLVING_ACTIVE'
+ """
+ A solver thread started solving the problem, but hasn't finished yet.
+ If CPU resource are scarce and that solver thread is waiting for CPU time,
+ the state doesn't change, it's still considered solving active.
+ Transitions into `NOT_SOLVING` when terminated.
+ """
@staticmethod
def _from_java_enum(enum_value):
@@ -29,53 +53,189 @@ def _from_java_enum(enum_value):
class SolverJob(Generic[Solution_, ProblemId_]):
+ """
+ Represents a problem that has been submitted to solve on the SolverManager.
+ """
_delegate: '_JavaSolverJob'
def __init__(self, delegate: '_JavaSolverJob'):
self._delegate = delegate
def get_problem_id(self) -> ProblemId_:
+ """
+ A value given to `SolverManager.solve`, `SolverManager.solve_and_listen` or
+ `SolverJobBuilder.with_problem_id`.
+
+ Returns
+ -------
+ ProblemId_
+ The problem id corresponding to this `SolverJob`.
+ """
from jpyinterpreter import unwrap_python_like_object
return unwrap_python_like_object(self._delegate.getProblemId())
def get_solver_status(self) -> SolverStatus:
+ """
+ Returns whether the `Solver` is scheduled to solve, actively solving or not.
+ Returns `SolverStatus.NOT_SOLVING` if the solver already terminated.
+
+ Returns
+ -------
+ SolverStatus
+ The `SolverStatus` for this `SolverJob`.
+ """
return SolverStatus._from_java_enum(self._delegate.getSolverStatus())
def get_solving_duration(self) -> timedelta:
+ """
+ Returns the duration spent solving since the last start.
+ If it hasn't started it yet, it returns ``timedelta(0)``.
+ If it hasn't ended yet, it returns the time between the last start and now.
+ If it has ended already, it returns the time between the last start and the ending.
+
+ Returns
+ -------
+ timedelta
+ The duration spent solving since the last (re)start, at least 0.
+ """
return timedelta(milliseconds=self._delegate.getSolvingDuration().toMillis())
def get_final_best_solution(self) -> Solution_:
+ """
+ Waits if necessary for the solver to complete and then returns the final best `planning_solution`.
+
+ Returns
+ -------
+ Solution_
+ Never ``None``, but it could be the original uninitialized problem.
+ """
from jpyinterpreter import unwrap_python_like_object
return unwrap_python_like_object(self._delegate.getFinalBestSolution())
def terminate_early(self) -> None:
+ """
+ Terminates the solver or cancels the solver job if it hasn't (re) started yet.
+ Does nothing if the solver already terminated.
+
+ Waits for the termination or cancellation to complete before returning.
+ During termination, a best_solution_consumer could still be called.
+ When the solver terminates, the final_best_solution_consumer is executed with the latest best solution.
+ These consumers run on a consumer thread independently of the termination
+ and may still run even after this method returns.
+ """
self._delegate.terminateEarly()
def is_terminated_early(self) -> bool:
+ """
+ Checks if `terminate_early` has been called on this `SolverJob`.
+
+ Returns
+ -------
+ bool
+ ``True`` if `terminate_early` has been called since the underlying `Solver` started solving.
+ """
return self._delegate.isTerminatedEarly()
- def add_problem_change(self, problem_change: ProblemChange[Solution_]) -> Future[None]:
- return wrap_completable_future(self._delegate.addProblemChange(ProblemChangeWrapper(problem_change)))
+ def add_problem_change(self, problem_change: ProblemChange[Solution_]) -> Awaitable[None]:
+ """
+ Schedules a `ProblemChange` to be processed by the underlying `Solver` and returns immediately.
+ To learn more about problem change semantics, please refer to the `ProblemChange` docstring.
+
+ Parameters
+ ----------
+ problem_change : ProblemChange
+ The `ProblemChange` to be processed.
+
+ Returns
+ -------
+ Awaitable
+ An awaitable that completes after the best solution containing this change has been consumed.
+ """
+ return wrap_future(self._delegate.addProblemChange(ProblemChangeWrapper(problem_change)))
class SolverJobBuilder(Generic[Solution_, ProblemId_]):
+ """
+ Provides a fluent contract that allows customization and submission of planning problems to solve.
+ A `SolverManager` can solve multiple planning problems and can be used across different threads.
+
+ Hence, it is possible to have multiple distinct build configurations
+ that are scheduled to run by the `SolverManager` instance.
+
+ To solve a planning problem, set the problem configuration:
+ `with_problem_id`, `with_problem_finder` and `with_problem`.
+
+ Then solve it by calling `run`.
+ """
_delegate: '_JavaSolverJobBuilder'
def __init__(self, delegate: '_JavaSolverJobBuilder'):
self._delegate = delegate
def with_problem_id(self, problem_id: ProblemId_) -> 'SolverJobBuilder':
+ """
+ Sets the problem id.
+
+ Parameters
+ ----------
+ problem_id : ProblemId_
+ A ID for each planning problem. This must be unique.
+
+ Returns
+ -------
+ SolverJobBuilder
+ This `SolverJobBuilder`.
+ """
from jpyinterpreter import convert_to_java_python_like_object
return SolverJobBuilder(self._delegate.withProblemId(convert_to_java_python_like_object(problem_id)))
def with_problem(self, problem: Solution_) -> 'SolverJobBuilder':
+ """
+ Sets the problem definition.
+
+ Parameters
+ ----------
+ problem : Solution_
+ A `planning_solution`, usually with uninitialized planning variables
+
+ Returns
+ -------
+ SolverJobBuilder
+ This `SolverJobBuilder`.
+ """
from jpyinterpreter import convert_to_java_python_like_object
return SolverJobBuilder(self._delegate.withProblem(convert_to_java_python_like_object(problem)))
def with_config_override(self, config_override: SolverConfigOverride) -> 'SolverJobBuilder':
+ """
+ Sets the solver config override.
+
+ Parameters
+ ----------
+ config_override : SolverConfigOverride
+ Allows overriding the default behavior of Solver
+
+ Returns
+ -------
+ SolverJobBuilder
+ This `SolverJobBuilder`.
+ """
return SolverJobBuilder(self._delegate.withConfigOverride(config_override._to_java_solver_config_override()))
def with_problem_finder(self, problem_finder: Callable[[ProblemId_], Solution_]) -> 'SolverJobBuilder':
+ """
+ Sets the mapping function to the problem definition.
+
+ Parameters
+ ----------
+ problem_finder : Callable[[ProblemId_], Solution_]
+ A function that returns a `planning_solution`, usually with uninitialized planning variables
+
+ Returns
+ -------
+ SolverJobBuilder
+ This `SolverJobBuilder`.
+ """
from java.util.function import Function
from jpyinterpreter import convert_to_java_python_like_object, unwrap_python_like_object
java_finder = Function @ (lambda problem_id: convert_to_java_python_like_object(
@@ -83,6 +243,19 @@ def with_problem_finder(self, problem_finder: Callable[[ProblemId_], Solution_])
return SolverJobBuilder(self._delegate.withProblemFinder(java_finder))
def with_best_solution_consumer(self, best_solution_consumer: Callable[[Solution_], None]) -> 'SolverJobBuilder':
+ """
+ Sets the best solution consumer, which may be called multiple times during the solving process.
+
+ Parameters
+ ----------
+ best_solution_consumer : Callable[[Solution_], None]
+ Called multiple times for each new best solution on a consumer thread
+
+ Returns
+ -------
+ SolverJobBuilder
+ This `SolverJobBuilder`.
+ """
from java.util.function import Consumer
from jpyinterpreter import unwrap_python_like_object
@@ -90,6 +263,20 @@ def with_best_solution_consumer(self, best_solution_consumer: Callable[[Solution
return SolverJobBuilder(self._delegate.withBestSolutionConsumer(java_consumer))
def with_final_best_solution_consumer(self, final_best_solution_consumer: Callable[[Solution_], None]) -> 'SolverJobBuilder':
+ """
+ Sets the final best solution consumer,
+ which is called at the end of the solving process and returns the final best solution.
+
+ Parameters
+ ----------
+ final_best_solution_consumer : Callable[[Solution_], None]
+ Called only once at the end of the solving process on a consumer thread
+
+ Returns
+ -------
+ SolverJobBuilder
+ This `SolverJobBuilder`.
+ """
from java.util.function import Consumer
from jpyinterpreter import unwrap_python_like_object
@@ -98,6 +285,20 @@ def with_final_best_solution_consumer(self, final_best_solution_consumer: Callab
self._delegate.withFinalBestSolutionConsumer(java_consumer))
def with_exception_handler(self, exception_handler: Callable[[ProblemId_, Exception], None]) -> 'SolverJobBuilder':
+ """
+ Sets the custom exception handler.
+
+ Parameters
+ ----------
+ exception_handler : Callable[[ProblemId_, Exception], None]
+ Called if an exception or error occurs.
+ If not present, it defaults to logging the exception as an error.
+
+ Returns
+ -------
+ SolverJobBuilder
+ This `SolverJobBuilder`.
+ """
from java.util.function import BiConsumer
from jpyinterpreter import unwrap_python_like_object
@@ -107,10 +308,32 @@ def with_exception_handler(self, exception_handler: Callable[[ProblemId_, Except
self._delegate.withExceptionHandler(java_consumer))
def run(self) -> SolverJob[Solution_, ProblemId_]:
+ """
+ Submits a planning problem to solve and returns immediately.
+ The planning problem is solved on a solver thread, as soon as one is available.
+
+ Returns
+ -------
+ SolverJob
+ The `SolverJob` built from this `SolverJobBuilder`.
+ """
return SolverJob(self._delegate.run())
class SolverManager(Generic[Solution_, ProblemId_]):
+ """
+ A `SolverManager` solves multiple planning problems of the same domain,
+ asynchronously without blocking the calling thread.
+ To create a `SolverManager`, use `create`.
+ To solve a planning problem, call `solve`, `solve_and_listen` or `solve_builder`.
+
+ These methods are thread-safe unless explicitly stated otherwise.
+
+ Internally a `SolverManager` manages a thread pool of solver threads (which call `Solver.solve`)
+ and consumer threads (to handle the `BestSolutionChangedEvents`).
+
+ To learn more about problem change semantics, please refer to the `ProblemChange` Javadoc.
+ """
_delegate: '_JavaSolverManager'
def __init__(self, delegate: '_JavaSolverManager'):
@@ -118,11 +341,55 @@ def __init__(self, delegate: '_JavaSolverManager'):
@staticmethod
def create(solver_factory: 'SolverFactory[Solution_]') -> 'SolverManager[Solution_, ProblemId_]':
+ """
+ Use a `SolverFactory` to build a `SolverManager`.
+
+ Parameters
+ ----------
+ solver_factory : SolverFactory[Solution_]
+ The `SolverFactory` to build the `SolverManager` from.
+
+ Returns
+ -------
+ SolverManager
+ A new `SolverManager` instance.
+ """
from ai.timefold.solver.core.api.solver import SolverManager as JavaSolverManager
return SolverManager(JavaSolverManager.create(solver_factory._delegate)) # noqa
def solve(self, problem_id: ProblemId_, problem: Solution_,
final_best_solution_listener: Callable[[Solution_], None] = None) -> SolverJob[Solution_, ProblemId_]:
+ """
+ Submits a planning problem to solve and returns immediately.
+ The planning problem is solved on a solver Thread, as soon as one is available.
+ To retrieve the final best solution, use `SolverJob.get_final_best_solution`.
+ In server applications, it's recommended to set `final_best_solution_listener`,
+ to avoid loading the problem going stale if solving can't start immediately.
+ To listen to intermediate best solutions too, use `solve_and_listen` instead.
+
+ Defaults to logging exceptions as an error.
+
+ To stop a solver job before it naturally terminates, call `SolverJob.terminate_early`.
+
+ Parameters
+ ----------
+ problem_id : ProblemId_
+ A ID for each planning problem.
+ This must be unique.
+ Use this problemId to terminate the solver early,
+ to get the status or if the problem changes while solving.
+
+ problem : Solution_
+ A `planning_solution` usually with uninitialized planning variables
+
+ final_best_solution_listener : Callable[[Solution_], None], optional
+ Called only once, at the end, on a consumer thread
+
+ Returns
+ -------
+ SolverJob
+ A new `SolverJob`.
+ """
builder = (self.solve_builder()
.with_problem_id(problem_id)
.with_problem(problem))
@@ -134,6 +401,36 @@ def solve(self, problem_id: ProblemId_, problem: Solution_,
def solve_and_listen(self, problem_id: ProblemId_, problem: Solution_, listener: Callable[[Solution_], None]) \
-> SolverJob[Solution_, ProblemId_]:
+ """
+ Submits a planning problem to solve and returns immediately.
+ The planning problem is solved on a solver thread, as soon as one is available.
+ When the solver finds a new best solution, the `best_solution_consumer` is called every time,
+ on a consumer thread, as soon as one is available (taking into account any throttling waiting time),
+ unless a newer best solution is already available by then (in which case skip ahead discards it).
+
+ Defaults to logging exceptions as an error.
+
+ To stop a solver job before it naturally terminates, call `terminate_early`.
+
+ Parameters
+ ----------
+ problem_id : ProblemId_
+ A ID for each planning problem.
+ This must be unique.
+ Use this problemId to terminate the solver early,
+ to get the status or if the problem changes while solving.
+
+ problem: Solution_
+ A `planning_solution` usually with uninitialized planning variables.
+
+ listener : Callable[[Solution_], None]
+ Called multiple times, on a consumer thread.
+
+ Returns
+ -------
+ SolverJob
+ A new `SolverJob`.
+ """
return (self.solve_builder()
.with_problem_id(problem_id)
.with_problem(problem)
@@ -141,29 +438,108 @@ def solve_and_listen(self, problem_id: ProblemId_, problem: Solution_, listener:
.run())
def solve_builder(self) -> SolverJobBuilder[Solution_, ProblemId_]:
+ """
+ Creates a `SolverJobBuilder` that allows to customize and submit a planning problem to solve.
+
+ Returns
+ -------
+ SolverJobBuilder
+ A new `SolverJobBuilder`.
+ """
return SolverJobBuilder(self._delegate.solveBuilder())
def get_solver_status(self, problem_id: ProblemId_) -> SolverStatus:
+ """
+ Returns if the Solver is scheduled to solve, actively solving or not.
+ Returns `SolverStatus.NOT_SOLVING` if the solver already terminated or if the `problem_id` was never added.
+ To distinguish between both cases, use `SolverJob.get_solver_status` instead.
+ Here, that distinction is not supported because it would cause a memory leak.
+
+ Parameters
+ ----------
+ problem_id : ProblemId_
+ A value given to `SolverManager.solve`, `SolverManager.solve_and_listen` or
+ `SolverJobBuilder.with_problem_id`.
+
+ Returns
+ -------
+ SolverStatus
+ The `SolverStatus` corresponding to `problem_id`.
+ """
from jpyinterpreter import convert_to_java_python_like_object
return SolverStatus._from_java_enum(self._delegate.getSolverStatus(
convert_to_java_python_like_object(problem_id)))
def terminate_early(self, problem_id: ProblemId_) -> None:
+ """
+ Terminates the solver or cancels the solver job if it hasn't (re)started yet.
+ Does nothing if the solver already terminated or the `problem_id` was never added.
+ To distinguish between both cases, use `SolverJob.terminate_early` instead.
+ Here, that distinction is not supported because it would cause a memory leak.
+
+ Waits for the termination or cancellation to complete before returning.
+ During termination, a `best_solution_consumer` could still be called.
+ When the solver terminates, the `final_best_solution_consumer` is executed with the latest best solution.
+ These consumers run on a consumer thread independently of the termination
+ and may still run even after this method returns.
+
+ Parameters
+ ----------
+ problem_id : ProblemId_
+ A value given to `SolverManager.solve`, `SolverManager.solve_and_listen` or
+ `SolverJobBuilder.with_problem_id`.
+ """
from jpyinterpreter import convert_to_java_python_like_object
self._delegate.terminateEarly(convert_to_java_python_like_object(problem_id))
- def add_problem_change(self, problem_id: ProblemId_, problem_change: ProblemChange[Solution_]) -> Future[None]:
+ def add_problem_change(self, problem_id: ProblemId_, problem_change: ProblemChange[Solution_]) -> Awaitable[None]:
+ """
+ Schedules a `ProblemChange` to be processed by the underlying `Solver` and returns immediately.
+ If the solver already terminated or the `problem_id` was never added, throws an exception.
+ The same applies if the underlying `Solver` is not in the `SolverStatus.SOLVING_ACTIVE` state.
+
+ Parameters
+ ----------
+ problem_id : ProblemId_
+ A value given to `SolverManager.solve`, `SolverManager.solve_and_listen` or
+ `SolverJobBuilder.with_problem_id`.
+ problem_change : ProblemChange
+ A problem change to be processed by the underlying `Solver`.
+
+ Returns
+ -------
+ Awaitable
+ An awaitable that completes after the best solution containing this change has been consumed.
+ """
from jpyinterpreter import convert_to_java_python_like_object
- return wrap_completable_future(self._delegate.addProblemChange(convert_to_java_python_like_object(problem_id),
+ return wrap_future(self._delegate.addProblemChange(convert_to_java_python_like_object(problem_id),
ProblemChangeWrapper(problem_change)))
def close(self) -> None:
+ """
+ Terminates all solvers,
+ cancels all solver jobs that haven't (re)started yet and discards all queued ProblemChanges.
+ Releases all thread pool resources.
+
+ No new planning problems can be submitted after calling this method.
+ """
self._delegate.close()
def __enter__(self) -> 'SolverManager[Solution_, ProblemId_]':
+ """
+ Returns self, so it can be used as a context manager.
+
+ Returns
+ -------
+ SolverManager
+ This `SolverManager`.
+ """
return self
def __exit__(self, exc_type, exc_val, exc_tb) -> None:
+ """
+ Calls `close` to release resources associated with this `SolverManager`.
+ """
self._delegate.close()
diff --git a/timefold-solver-python-core/src/main/python/_timefold_java_interop.py b/timefold-solver-python-core/src/main/python/_timefold_java_interop.py
index 66bf9be..e01c476 100644
--- a/timefold-solver-python-core/src/main/python/_timefold_java_interop.py
+++ b/timefold-solver-python-core/src/main/python/_timefold_java_interop.py
@@ -3,7 +3,7 @@
import jpype.imports
from jpype.types import *
import importlib.resources
-from typing import cast, List, Type, TypeVar, Callable, Union, TYPE_CHECKING, Any
+from typing import cast, TypeVar, Callable, Union, TYPE_CHECKING, Any
from ._jpype_type_conversions import PythonSupplier, ConstraintProviderFunction
if TYPE_CHECKING:
@@ -26,14 +26,12 @@ def is_enterprise_installed() -> bool:
def extract_timefold_jars() -> list[str]:
- """Extracts and return a list of timefold Java dependencies
-
- Invoking this function extracts timefold Dependencies from the timefold.solver.jars module
- into a temporary directory and returns a list contains classpath entries for
- those dependencies. The temporary directory exists for the entire execution of the
- program.
-
- :return: None
+ """
+ Returns
+ -------
+ list[str]
+ a list contains classpath entries for
+ Timefold Solver's dependencies.
"""
global _enterprise_installed
try:
@@ -51,16 +49,25 @@ def extract_timefold_jars() -> list[str]:
if p.name.endswith(".jar")] + enterprise_dependencies
-def init(*args, path: List[str] = None, include_timefold_jars: bool = True, log_level='INFO'):
- """Start the JVM. Throws a RuntimeError if it is already started.
-
- :param args: JVM args.
- :param path: If not None, a list of dependencies to use as the classpath. Default to None.
- :param include_timefold_jars: If True, add timefold jars to path. Default to True.
- :param log_level: What Timefold's log level should be set to.
- Must be one of 'TRACE', 'DEBUG', 'INFO', 'WARN', 'ERROR'.
- Defaults to 'INFO'
- :return: None
+def init(*args, path: list[str] = None, include_timefold_jars: bool = True, log_level='INFO') -> None:
+ """
+ Most users will never need to call this method.
+ Only call this method if you are using other Java libraries.
+ This method will automatically be called when a Timefold class is instantiated.
+
+ Initializes the JVM.
+
+ Parameters
+ ----------
+ args : list[str]
+ JVM arguments.
+ path : list[str], optional
+ List of dependencies to use as the classpath.
+ include_timefold_jars : bool, optional
+ If the Timefold dependencies should be added to `path`.
+ Defaults to True.
+ log_level : str
+ The logging level to use.
"""
from jpyinterpreter import init
if jpype.isJVMStarted(): # noqa
@@ -78,12 +85,8 @@ def init(*args, path: List[str] = None, include_timefold_jars: bool = True, log_
def ensure_init():
- """Start the JVM if it isn't started; does nothing otherwise
-
- Used by timefold to start the JVM when needed by a method, so
- users don't need to start the JVM themselves.
-
- :return: None
+ """
+ Start the JVM if it isn't started; does nothing otherwise
"""
if jpype.isJVMStarted(): # noqa
return
@@ -92,25 +95,23 @@ def ensure_init():
def set_class_output_directory(path: pathlib.Path):
+ """
+ Sets the output directory for classes generated by Timefold Solver.
+ By default, the classes are only stored in memory.
+
+ Parameters
+ ----------
+ path : pathlib.Path
+ Path to the output directory.
+ It will be created if it doesn't exist.
+ """
ensure_init()
from ai.timefold.jpyinterpreter import PythonBytecodeToJavaBytecodeTranslator # noqa
PythonBytecodeToJavaBytecodeTranslator.classOutputRootPath = path
-solver_run_id_to_refs = dict()
-"""Maps solver run id to solution clones it references"""
-
-
-def _setup_solver_run(solver_run_id, solver_run_ref_list):
- solver_run_id_to_refs[solver_run_id] = solver_run_ref_list
-
-
-def _cleanup_solver_run(solver_run_id):
- del solver_run_id_to_refs[solver_run_id]
-
-
-def get_class(python_class: Union[Type, Callable]) -> JClass:
+def get_class(python_class: Union[type, Callable]) -> JClass:
"""Return the Java Class for the given Python Class"""
from java.lang import Object, Class
from ai.timefold.jpyinterpreter.types.wrappers import OpaquePythonReference
@@ -140,7 +141,7 @@ def get_class(python_class: Union[Type, Callable]) -> JClass:
return cast(JClass, Object)
-def get_asm_type(python_class: Union[Type, Callable]) -> Any:
+def get_asm_type(python_class: Union[type, Callable]) -> Any:
"""Return the ASM type for the given Python Class"""
from java.lang import Object, Class
from ai.timefold.jpyinterpreter import AnnotationMetadata
@@ -207,10 +208,6 @@ def _compose_unique_class_name(class_identifier: str):
return unique_class_name
-def _does_class_define_eq_or_hashcode(python_class):
- return '__eq__' in python_class.__dict__ or '__hash__' in python_class.__dict__
-
-
class OverrideClassLoader:
thread_class_loader: 'ClassLoader'
@@ -254,7 +251,7 @@ def _process_compilation_queue() -> None:
compile_class(python_class)
-def _to_constraint_java_array(python_list: List['_Constraint']) -> JArray:
+def _to_constraint_java_array(python_list: list['_Constraint']) -> JArray:
# reimport since the one in global scope is only for type checking
import ai.timefold.solver.core.api.score.stream.Constraint as ActualConstraintClass
out = jpype.JArray(ActualConstraintClass)(len(python_list))
@@ -263,9 +260,9 @@ def _to_constraint_java_array(python_list: List['_Constraint']) -> JArray:
return out
-def _generate_constraint_provider_class(original_function: Callable[['_ConstraintFactory'], List['_Constraint']],
+def _generate_constraint_provider_class(original_function: Callable[['_ConstraintFactory'], list['_Constraint']],
wrapped_constraint_provider: Callable[['_ConstraintFactory'],
- List['_Constraint']]) -> JClass:
+ list['_Constraint']]) -> JClass:
ensure_init()
from ai.timefold.solver.python import PythonWrapperGenerator # noqa
from ai.timefold.solver.core.api.score.stream import ConstraintProvider
diff --git a/timefold-solver-python-core/src/main/python/config/__init__.py b/timefold-solver-python-core/src/main/python/config/__init__.py
index 0f965ec..9392743 100644
--- a/timefold-solver-python-core/src/main/python/config/__init__.py
+++ b/timefold-solver-python-core/src/main/python/config/__init__.py
@@ -1 +1,19 @@
+"""
+Classes used to configure the `Solver`.
+
+Examples
+--------
+>>> from timefold.solver.config import (SolverConfig, ScoreDirectorFactoryConfig,
+... TerminationConfig, Duration)
+>>> from domain import Timetable, Lesson
+>>> from constraints import my_constraints
+>>>
+>>> solver_config = SolverConfig(solution_class=Timetable, entity_class_list=[Lesson],
+... score_director_factory_config=ScoreDirectorFactoryConfig(
+... constraint_provider_function=my_constraints
+... ),
+... termination_config=TerminationConfig(
+... spent_limit=Duration(seconds=30))
+... )
+"""
from ._config import *
diff --git a/timefold-solver-python-core/src/main/python/config/_config.py b/timefold-solver-python-core/src/main/python/config/_config.py
index b9f641d..d625fc1 100644
--- a/timefold-solver-python-core/src/main/python/config/_config.py
+++ b/timefold-solver-python-core/src/main/python/config/_config.py
@@ -25,6 +25,9 @@ def _lookup_on_java_class(java_class: str, attribute: str) -> Any:
@dataclass(kw_only=True)
class Duration:
+ """
+ Represents a duration of time.
+ """
milliseconds: int = field(default=0)
seconds: int = field(default=0)
minutes: int = field(default=0)
@@ -64,12 +67,78 @@ def _to_java_duration(self) -> '_JavaDuration':
class EnvironmentMode(Enum):
+ """
+ The environment mode also allows you to detect common bugs in your implementation.
+ Also, a `Solver` has a single Random instance.
+ Some optimization algorithms use the Random instance a lot more than others.
+ For example simulated annealing depends highly on random numbers,
+ while tabu search only depends on it to deal with score ties.
+ This environment mode influences the seed of that Random instance.
+ """
+
NON_REPRODUCIBLE = 'NON_REPRODUCIBLE'
+ """
+ The non reproducible mode is equally fast or slightly faster than REPRODUCIBLE.
+ The random seed is different on every run,
+ which makes it more robust against an unlucky random seed.
+ An unlucky random seed gives a bad result on a certain data set with a certain solver configuration.
+ Note that in most use cases the impact of the random seed is relatively low on the result.
+ An occasional bad result is far more likely to be caused by another issue (such as a score trap).
+ In multithreaded scenarios, this mode allows the use of work stealing and other non deterministic speed tricks.
+ """
+
REPRODUCIBLE = 'REPRODUCIBLE'
+ """
+ The reproducible mode is the default mode because it is recommended during development.
+ In this mode, 2 runs on the same computer will execute the same code in the same order.
+ They will also yield the same result,
+ except if they use a time based termination and they have a sufficiently large difference in allocated CPU time.
+ This allows you to benchmark new optimizations (such as a new Move implementation)
+ fairly and reproduce bugs in your code reliably.
+
+ Warning: some code can disrupt reproducibility regardless of this mode.
+ See the reference manual for more info.
+ In practice, this mode uses the default random seed,
+ and it also disables certain concurrency optimizations (such as work stealing).
+ """
+
FAST_ASSERT = 'FAST_ASSERT'
+ """
+ This mode turns on several assertions (but not all of them) to fail-fast on a bug in a Move implementation,
+ a constraint rule, the engine itself or something else at a reasonable performance cost (in development at least).
+ This mode is reproducible (see REPRODUCIBLE mode).
+ This mode is intrusive because it calls calculate_score more frequently than a non assert mode.
+ This mode is slow.
+ """
+
NON_INTRUSIVE_FULL_ASSERT = 'NON_INTRUSIVE_FULL_ASSERT'
+ """
+ This mode turns on several assertions (but not all of them) to fail-fast on a bug in a Move implementation,
+ a constraint, the engine itself or something else at an overwhelming performance cost.
+ This mode is reproducible (see REPRODUCIBLE mode).
+ This mode is non-intrusive, unlike FULL_ASSERT and FAST_ASSERT.
+ This mode is horribly slow.
+ """
+
FULL_ASSERT = 'FULL_ASSERT'
+ """
+ This mode turns on all assertions to fail-fast on a bug in a Move implementation,
+ a constraint, the engine itself or something else at a horrible performance cost.
+ This mode is reproducible (see REPRODUCIBLE mode).
+ This mode is intrusive because it calls calculate_score more frequently than a non assert mode.
+ This mode is horribly slow.
+ """
+
TRACKED_FULL_ASSERT = 'TRACKED_FULL_ASSERT'
+ """
+ This mode turns on FULL_ASSERT and enables variable tracking to fail-fast on a bug in a Move implementation,
+ a constraint, the engine itself or something else at the highest performance cost.
+ Because it tracks genuine and shadow variables,
+ it is able to report precisely what variables caused the corruption and report any missed VariableListener events.
+ This mode is reproducible (see REPRODUCIBLE mode).
+ This mode is intrusive because it calls calculate_score more frequently than a non assert mode.
+ This mode is by far the slowest of all the modes.
+ """
def _get_java_enum(self):
return _lookup_on_java_class(_java_environment_mode, self.name)
@@ -85,7 +154,15 @@ def _get_java_enum(self):
class MoveThreadCount(Enum):
AUTO = 'AUTO'
+ """
+ Configure the number of move threads dynamically based on the
+ computer's core count.
+ """
+
NONE = 'NONE'
+ """
+ Disables multithreaded solving.
+ """
class RequiresEnterpriseError(EnvironmentError):
@@ -101,6 +178,10 @@ def __init__(self, feature):
@dataclass(kw_only=True)
class SolverConfig(Generic[Solution_]):
+ """
+ To read it from XML, use `create_from_xml_resource`.
+ To build a `SolverFactory` with it, use `SolverFactory.create`.
+ """
solution_class: Optional[type[Solution_]] = field(default=None)
entity_class_list: Optional[list[type]] = field(default=None)
environment_mode: Optional[EnvironmentMode] = field(default=EnvironmentMode.REPRODUCIBLE)
@@ -279,6 +360,14 @@ def _to_java_termination_config(self, inherited_config: '_JavaTerminationConfig'
@dataclass(kw_only=True)
class SolverConfigOverride:
+ """
+ Includes settings to override default Solver configuration.
+
+ Attributes
+ ----------
+ termination_config: TerminationConfig, optional
+ sets the solver TerminationConfig.
+ """
termination_config: Optional[TerminationConfig] = field(default=None)
def _to_java_solver_config_override(self):
diff --git a/timefold-solver-python-core/src/main/python/domain/__init__.py b/timefold-solver-python-core/src/main/python/domain/__init__.py
index a92e790..b8211eb 100644
--- a/timefold-solver-python-core/src/main/python/domain/__init__.py
+++ b/timefold-solver-python-core/src/main/python/domain/__init__.py
@@ -1,3 +1,30 @@
+"""
+Annotations, classes and decorators used to
+define the domain of a planning problem.
+See `the modeling planning problems section in Timefold Solver documentation
+`_.
+
+Examples
+--------
+>>> from timefold.domain import PlanningVariable, PlanningId, planning_entity
+>>> from typing import Annotated
+>>> from datetime import datetime
+>>>
+>>> class Room:
+... id: Annotated[str, PlanningId]
+...
+>>> class Timeslot:
+... id: Annotated[str, PlanningId]
+... start: datetime
+... end: datetime
+...
+>>> @planning_entity
+>>> class Lesson:
+... id: Annotated[str, PlanningId]
+... teacher: str
+... room: Annotated[Room, PlanningVariable]
+... timeslot: Annotated[Timeslot, PlanningVariable]
+"""
from ._annotations import *
from ._value_range import *
from ._variable_listener import *
diff --git a/timefold-solver-python-core/src/main/python/domain/_annotations.py b/timefold-solver-python-core/src/main/python/domain/_annotations.py
index 4264e92..b24fb08 100644
--- a/timefold-solver-python-core/src/main/python/domain/_annotations.py
+++ b/timefold-solver-python-core/src/main/python/domain/_annotations.py
@@ -14,6 +14,28 @@
class PlanningId(JavaAnnotation):
+ """
+ Specifies that an attribute is the id to match when locating
+ an external object (often from another thread).
+ Used during Move rebasing and in a ProblemChange.
+ It is specified on an attribute of a `planning_entity` class,
+ planning value class or any problem fact class.
+ The return type can be any comparable type that overrides
+ ``__eq__`` and ``__hash__``, and is usually ``int`` or ``str``.
+ It must never return a ``None`` instance.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import PlanningId
+ >>> from typing import Annotated
+ >>>
+ >>> class Room:
+ ... id: Annotated[str, PlanningId]
+
+ See Also
+ --------
+ planning_entity
+ """
def __init__(self):
ensure_init()
from ai.timefold.solver.core.api.domain.lookup import PlanningId as JavaPlanningId
@@ -21,10 +43,48 @@ def __init__(self):
class PlanningPin:
+ """
+ Specifies that a boolean attribute of a `planning_entity` determines if the planning entity is pinned.
+ A pinned planning entity is never changed during planning.
+ For example,
+ it allows the user to pin a shift to a specific employee before solving and the solver will not undo that,
+ regardless of the constraints.
+
+ The boolean is ``False`` if the planning entity is movable and ``True`` if the planning entity is pinned.
+ It applies to all the planning variables of that planning entity.
+ If set on an entity with PlanningListVariable, this will pin the entire list of planning values as well.
+ This is syntactic sugar for ``@planning_entity(pinning_filter=...)``,
+ which is a more flexible and verbose way to pin a planning entity.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import PlanningPin, planning_entity
+ >>> from typing import Annotated
+ >>>
+ >>> @planning_entity
+ ... class Lesson:
+ ... is_pinned: Annotated[bool, PlanningPin]
+ """
pass
class PlanningVariable(JavaAnnotation):
+ """
+ Specifies that an attribute can be changed and should be optimized by the optimization algorithms.
+ It is specified on an attribute of a `planning_entity` class.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import PlanningVariable, planning_entity
+ >>> from typing import Annotated
+ >>> from domain import Room, Timeslot
+ >>>
+ >>> @planning_entity
+ ... class Lesson:
+ ... teacher: str
+ ... room: Annotated[Room, PlanningVariable]
+ ... timeslot: Annotated[Timeslot, PlanningVariable]
+ """
def __init__(self, *,
value_range_provider_refs: List[str] = None,
allows_unassigned: bool = False,
@@ -40,6 +100,47 @@ def __init__(self, *,
class PlanningListVariable(JavaAnnotation):
+ """
+ Specifies that an attribute can be changed and should be optimized by the optimization algorithms.
+ It is specified on an attribute of a `planning_entity` class.
+ The type of the PlanningListVariable annotated attribute must be ``list[Value]``.
+
+ List variable
+ -------------
+ A planning entity's attribute annotated with `PlanningListVariable` is referred to as a list variable.
+ The way solver optimizes a list variable is by adding, removing,
+ or changing order of elements in the `list` object held by the list variable.
+
+ Disjoint lists
+ --------------
+ Furthermore,
+ the current implementation works under the assumption
+ that the list variables of all entity instances are "disjoint lists":
+
+ - List means that the order of elements inside a list planning variable is significant.
+ - Disjoint means that any given pair of entities have no common elements in their list variables.
+ In other words, each element from the list variable's value range appears in exactly one entity's list variable.
+
+ This makes sense for common use cases,
+ for example the Vehicle Routing Problem or Task Assigning.
+ In both cases the order in which customers are visited and tasks are being worked on matters.
+ Also, each customer must be visited once and each task must be completed by exactly one employee.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import PlanningListVariable, planning_entity
+ >>> from typing import Annotated
+ >>> from domain import Visit
+ >>>
+ >>> @planning_entity
+ ... class Vehicle:
+ ... visits: Annotated[list[Visit], PlanningListVariable]
+
+ See Also
+ --------
+ PlanningPin
+ PlanningPinToIndex
+ """
def __init__(self, *,
value_range_provider_refs: List[str] = None,
allows_unassigned_values: bool = False):
@@ -53,8 +154,35 @@ def __init__(self, *,
class ShadowVariable(JavaAnnotation):
+ """
+ Specifies that an attribute is a custom shadow variable of one or more source variables.
+ The source variable may be a genuine `PlanningVariable`, `PlanningListVariable`, or another shadow variable.
+ It is specified on an attribute of a `planning_entity` class.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import ShadowVariable, PreviousElementShadowVariable, planning_entity
+ >>> from typing import Annotated
+ >>> from domain import ArrivalTimeVariableListener
+ >>> from datetime import datetime
+ >>>
+ >>> @planning_entity
+ >>> class Visit:
+ ... previous: Annotated['Visit', PreviousElementShadowVariable]
+ ... arrival_time: Annotated[datetime,
+ ... ShadowVariable(
+ ... variable_listener_class=ArrivalTimeVariableListener,
+ ... source_variable_name='previous'
+ ... )
+ ... ]
+
+ See Also
+ --------
+ VariableListener
+ PiggybackShadowVariable
+ """
def __init__(self, *,
- variable_listener_class: Type[VariableListener] = None,
+ variable_listener_class: Type[VariableListener],
source_variable_name: str,
source_entity_class: Type = None):
ensure_init()
@@ -71,6 +199,33 @@ def __init__(self, *,
class PiggybackShadowVariable(JavaAnnotation):
+ """
+ Specifies that an attribute is a custom shadow variable that is
+ updated by another shadow variable's variable listener.
+ It is specified on an attribute of a `planning_entity` class.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import ShadowVariable, PreviousElementShadowVariable, planning_entity
+ >>> from typing import Annotated
+ >>> from domain import ArrivalTimeVariableListener
+ >>> from datetime import datetime
+ >>>
+ >>> @planning_entity
+ >>> class Visit:
+ ... previous: Annotated['Visit', PreviousElementShadowVariable]
+ ... arrival_time: Annotated[datetime,
+ ... ShadowVariable(
+ ... variable_listener_class=ArrivalTimeVariableListener,
+ ... source_variable_name='previous'
+ ... )
+ ... departure_time: Annotated[datetime,
+ ... PiggybackShadowVariable(shadow_variable_name='arrival_time')
+
+ See Also
+ --------
+ VariableListener
+ """
def __init__(self, *,
shadow_variable_name: str,
shadow_entity_class: Type = None):
@@ -86,6 +241,20 @@ def __init__(self, *,
class IndexShadowVariable(JavaAnnotation):
+ """
+ Specifies that an attribute is an index of this planning value in another entity's `PlanningListVariable`.
+ It is specified on an attribute of a `planning_entity` class.
+ The source variable must be a list variable.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import IndexShadowVariable, planning_entity
+ >>> from typing import Annotated
+ >>>
+ >>> @planning_entity
+ ... class Visit:
+ ... visit_index: Annotated[int, IndexShadowVariable]
+ """
def __init__(self, *,
source_variable_name: str):
ensure_init()
@@ -99,6 +268,22 @@ def __init__(self, *,
class PreviousElementShadowVariable(JavaAnnotation):
+ """
+ Specifies that an attribute references the previous element in the same `PlanningListVariable`.
+ The previous element's index is one lower than this element's index.
+ It is ``None`` if this element is the first element in the list variable.
+ It is specified on an attribute of a `planning_entity` class.
+ The source variable must be a list variable.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import PreviousElementShadowVariable, planning_entity
+ >>> from typing import Annotated
+ >>>
+ >>> @planning_entity
+ >>> class Visit:
+ ... previous: Annotated['Visit', PreviousElementShadowVariable]
+ """
def __init__(self, *,
source_variable_name: str):
ensure_init()
@@ -112,6 +297,13 @@ def __init__(self, *,
class NextElementShadowVariable(JavaAnnotation):
+ """
+ Specifies that an attribute references the next element in the same `PlanningListVariable`.
+ The next element's index is one higher than this element's index.
+ It is ``None`` if this element is the last element in the list variable.
+ It is specified on an attribute of a `planning_entity` class.
+ The source variable must be a list variable.
+ """
def __init__(self, *,
source_variable_name: str):
ensure_init()
@@ -125,6 +317,11 @@ def __init__(self, *,
class AnchorShadowVariable(JavaAnnotation):
+ """
+ Specifies that an attribute is the anchor of a chained `PlanningVariable`,
+ which implies it's a shadow variable.
+ It is specified on an attribute of a `planning_entity` class.
+ """
def __init__(self, *,
source_variable_name: str):
ensure_init()
@@ -138,6 +335,21 @@ def __init__(self, *,
class InverseRelationShadowVariable(JavaAnnotation):
+ """
+ Specifies that an attribute is the inverse of a `PlanningVariable`,
+ which implies it's a shadow variable.
+ It is specified on an attribute of a `planning_entity` class.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import InverseRelationShadowVariable, planning_entity
+ >>> from typing import Annotated
+ >>> from domain import Vehicle
+ >>>
+ >>> @planning_entity
+ >>> class Visit:
+ ... vehicle: Annotated[Vehicle, InverseRelationShadowVariable(source_variable_name='visits')]
+ """
def __init__(self, *,
source_variable_name: str):
ensure_init()
@@ -151,6 +363,29 @@ def __init__(self, *,
class ProblemFactProperty(JavaAnnotation):
+ """
+ Specifies that an attribute on a `planning_solution` class is a problem fact.
+ A problem fact must not change during solving (except through a `ProblemChange` event).
+ The constraints in a `timefold.solver.score.ConstraintProvider` rely on problem facts for
+ `timefold.solver.score.ConstraintFactory.for_each`.
+ Do not annotate a planning entity or a planning paramerization as a problem fact:
+ they are automatically available as facts for `timefold.solver.score.ConstraintFactory.for_each`.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import ProblemFactProperty, planning_solution
+ >>> from typing import Annotated
+ >>> from domain import School
+ >>>
+ >>> @planning_solution
+ >>> class Timetable:
+ ... school: Annotated[School, ProblemFactProperty]
+ ... # ...
+
+ See Also
+ --------
+ ProblemFactCollectionProperty
+ """
def __init__(self):
ensure_init()
from ai.timefold.solver.core.api.domain.solution import (
@@ -159,6 +394,29 @@ def __init__(self):
class ProblemFactCollectionProperty(JavaAnnotation):
+ """
+ Specifies that an attribute on a `planning_solution` class is a collection of problem facts.
+ A problem fact must not change during solving (except through a `ProblemChange` event).
+ The constraints in a `timefold.solver.score.ConstraintProvider` rely on problem facts for
+ `timefold.solver.score.ConstraintFactory.for_each`.
+ Do not annotate a planning entity or a planning paramerization as a problem fact:
+ they are automatically available as facts for `timefold.solver.score.ConstraintFactory.for_each`.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import ProblemFactCollectionProperty, planning_solution
+ >>> from typing import Annotated
+ >>> from domain import School
+ >>>
+ >>> @planning_solution
+ >>> class Timetable:
+ ... schools: Annotated[list[School], ProblemFactCollectionProperty]
+ ... # ...
+
+ See Also
+ --------
+ ProblemFactProperty
+ """
def __init__(self):
ensure_init()
from ai.timefold.solver.core.api.domain.solution import (
@@ -167,6 +425,22 @@ def __init__(self):
class PlanningEntityProperty(JavaAnnotation):
+ """
+ Specifies that an attribute on a `planning_solution` class is a planning entity.
+ The planning entity class should be decorated by the `planning_entity` decorator.
+ The planning entity will be added to the `ScoreDirector`.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import PlanningEntityProperty, planning_solution
+ >>> from typing import Annotated
+ >>> from domain import Lesson
+ >>>
+ >>> @planning_solution
+ >>> class Timetable:
+ ... lesson: Annotated[Lesson, PlanningEntityProperty]
+ ... # ...
+ """
def __init__(self):
ensure_init()
from ai.timefold.solver.core.api.domain.solution import (
@@ -175,6 +449,22 @@ def __init__(self):
class PlanningEntityCollectionProperty(JavaAnnotation):
+ """
+ Specifies that an attribute on a `planning_solution` class is a collection of planning entities.
+ The class of every element in the planning entity collection should be decorated by the `planning_entity` decorator.
+ Every element in the planning entity collection will be added to the `ScoreDirector`.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import PlanningEntityCollectionProperty, planning_solution
+ >>> from typing import Annotated
+ >>> from domain import Lesson
+ >>>
+ >>> @planning_solution
+ >>> class Timetable:
+ ... lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty]
+ ... # ...
+ """
def __init__(self):
ensure_init()
from ai.timefold.solver.core.api.domain.solution import (
@@ -183,6 +473,22 @@ def __init__(self):
class ValueRangeProvider(JavaAnnotation):
+ """
+ Provides the planning values that can be used for a `PlanningVariable`.
+ This is specified on an attribute which returns a collection (like `list` or `set`) or ValueRange.
+ A collection is implicitly converted to a ValueRange.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import ProblemFactCollectionProperty, ValueRangeProvider, planning_solution
+ >>> from typing import Annotated
+ >>> from domain import Room
+ >>>
+ >>> @planning_solution
+ >>> class Timetable:
+ ... rooms: Annotated[list[Room], ProblemFactCollectionProperty, ValueRangeProvider]
+ ... # ...
+ """
def __init__(self, *, id: str = None):
ensure_init()
from ai.timefold.solver.core.api.domain.valuerange import (
@@ -193,6 +499,23 @@ def __init__(self, *, id: str = None):
class PlanningScore(JavaAnnotation):
+ """
+ Specifies that an attribute on a `planning_solution` class holds the `timefold.score.Score` of that solution.
+ This attribute can be ``None`` if the planning solution is uninitialized.
+ This attribute is modified by the Solver,
+ every time when the `timefold.score.Score` of this planning solution has been calculated.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import PlanningScore, planning_solution
+ >>> from timefold.solver.score import HardSoftScore
+ >>> from typing import Annotated
+ >>>
+ >>> @planning_solution
+ >>> class Timetable:
+ ... score: Annotated[HardSoftScore, PlanningScore]
+ ... # ...
+ """
def __init__(self, *,
bendable_hard_levels_size: int = None,
bendable_soft_levels_size: int = None):
@@ -207,6 +530,30 @@ def __init__(self, *,
class DeepPlanningClone(JavaAnnotation):
+ """
+ Marks a problem fact class as being required to be deep planning cloned.
+ Not needed for a `planning_solution` or `planning_entity` because those are automatically deep cloned.
+ It can also mark an attribute as being required to be deep planning cloned.
+ This is especially useful for `list` (or `dict`) properties.
+ Not needed for a `list` (or `dist`) attribute with a generic type of `planning_entity`,
+ because those are automatically deep cloned.
+
+ Notes
+ -----
+ If it annotates an attribute returning `list` (or `dict`),
+ it clones the `list` (or `dict`),
+ but its elements (or keys and values) are only cloned if they are of a type that needs to be planning cloned.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import DeepPlanningClone, ShadowVariable, planning_entity
+ >>> from datetime import date
+ >>> from typing import Annotated
+ >>>
+ >>> @planning_entity
+ ... class Employee:
+ ... work_day_to_hours: Annotated[dict[date, int], ShadowVariable(...), DeepPlanningClone]
+ """
def __init__(self):
ensure_init()
from ai.timefold.solver.core.api.domain.solution.cloner import (
@@ -215,6 +562,21 @@ def __init__(self):
class ConstraintConfigurationProvider(JavaAnnotation):
+ """
+ Specifies that an attribute on a `planning_solution` class is a `constraint_configuration`.
+ This attribute is automatically a ProblemFactProperty too, so no need to declare that explicitly.
+ The type of this attribute must be decorated by the `constraint_configuration` decorator.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import ConstraintConfigurationProvider, planning_solution
+ >>> from typing import Annotated
+ >>> from domain import MyConstraintConfiguration
+ >>>
+ >>> @planning_solution
+ ... class Timetable:
+ ... configuration: Annotated[MyConstraintConfiguration, ConstraintConfigurationProvider]
+ """
def __init__(self):
ensure_init()
from ai.timefold.solver.core.api.domain.constraintweight import (
@@ -223,6 +585,22 @@ def __init__(self):
class ConstraintWeight(JavaAnnotation):
+ """
+ Specifies that an attribute set the constraint weight and score level of a constraint.
+ For example, with a constraint weight of 2soft,
+ a constraint match penalization with weight multiplier of 3 will result in a Score of -6soft.
+ It is specified on an attribute of a `constraint_configuration` class.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import ConstraintWeight, constraint_configuration
+ >>> from timefold.solver.score import HardSoftScore
+ >>> from typing import Annotated
+ >>>
+ >>> @constraint_configuration
+ ... class ConstraintConfiguration:
+ ... maximize_value: Annotated[HardSoftScore, ConstraintWeight('Maximize value')]
+ """
def __init__(self, constraint_name: str, *, constraint_package: str = None):
ensure_init()
from ai.timefold.solver.core.api.domain.constraintweight import ConstraintWeight as JavaConstraintWeight
@@ -244,24 +622,40 @@ def accept(self, solution, entity):
def planning_entity(entity_class: Type = None, /, *, pinning_filter: Callable = None) -> Union[Type,
Callable[[Type], Type]]:
- """Specifies that the class is a planning entity. Each planning entity must have at least
- 1 PlanningVariable property.
-
- The class MUST allow passing None to all of __init__ arguments, so it can be cloned.
- (ex: this is allowed:
-
- def __init__(self, a_list):
- self.a_list = a_list
-
- this is NOT allowed:
-
- def __init__(self, a_list):
- self.a_list = a_list
- self.list_length = len(a_list)
- )
-
- Optional Parameters: @:param pinning_filter: A function that takes the @planning_solution class and an entity,
- and return true if the entity cannot be changed, false otherwise
+ """
+ Specifies that the class is a planning entity.
+ There are two types of entities:
+
+ - Genuine entity
+ Must have at least one genuine planning variable, and zero or more shadow variables.
+
+ - Shadow entity
+ Must have at least one shadow variable, and no genuine variables.
+
+ If a planning entity has neither a genuine nor a shadow variable,
+ it is not a planning entity and the solver will fail fast.
+
+ Parameters
+ ----------
+ pinning_filter : Callable[[Solution, Entity], bool], optional
+ A pinned planning entity is never changed during planning,
+ this is useful in repeated planning use cases (such as continuous planning and real-time planning).
+ This applies to all the planning variables of this planning entity.
+
+ The predicate should return ``False`` if the selection entity is pinned,
+ and it should return ``True`` if the selection entity is movable
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import PlanningVariable, planning_entity
+ >>> from typing import Annotated
+ >>> from domain import Timeslot, Room
+ >>>
+ >>> @planning_entity
+ ... class Lesson:
+ ... teacher: str
+ ... room: Annotated[Room, PlanningVariable]
+ ... timeslot: Annotated[Timeslot, PlanningVariable]
"""
ensure_init()
from ai.timefold.solver.core.api.domain.entity import PlanningEntity as JavaPlanningEntity
@@ -305,30 +699,41 @@ def planning_entity_wrapper(entity_class_argument):
def planning_solution(planning_solution_class: Type[Solution_]) -> Type[Solution_]:
- """Specifies that the class is a planning solution (represents a problem and a possible solution of that problem).
-
+ """
+ Specifies that the class is a planning solution.
+ A solution represents a problem and a possible solution of that problem.
A possible solution does not need to be optimal or even feasible.
A solution's planning variables might not be initialized (especially when delivered as a problem).
- A solution is mutable. For scalability reasons (to facilitate incremental score calculation),
+ A solution is mutable.
+ For scalability reasons (to facilitate incremental score calculation),
the same solution instance (called the working solution per move thread) is continuously modified.
It's cloned to recall the best solution.
- Each planning solution must have exactly 1 PlanningScore property.
- Each planning solution must have at least 1 PlanningEntityCollectionProperty property.
-
- The class MUST allow passing None to all of __init__ arguments, so it can be cloned.
- (ex: this is allowed:
-
- def __init__(self, a_list):
- self.a_list = a_list
-
- this is NOT allowed:
-
- def __init__(self, a_list):
- self.a_list = a_list
- self.list_length = len(a_list)
- )
+ Each planning solution must have exactly one `PlanningScore` annotated attribute.
+ Each planning solution must have at least one `PlanningEntityCollectionProperty` or `PlanningEntityProperty`
+ annotated attribute.
+ Each planning solution is recommended to have one `ConstraintConfigurationProvider` annotated attribute too.
+ Each planning solution
+ used with ConstraintStream score calculation must have at least one `ProblemFactCollectionProperty` or
+ `ProblemFactProperty` annotated attribute.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import (PlanningScore, PlanningEntityCollectionProperty,
+ ... ProblemFactCollectionProperty, ValueRangeProvider,
+ ... ConstraintConfigurationProvider, planning_solution)
+ ... from timefold.solver.score import HardSoftScore
+ >>> from typing import Annotated
+ >>> from domain import Lesson, Room, Timeslot, TimetablingConstraintConfiguration
+ >>>
+ >>> @planning_solution
+ ... class Timetable:
+ ... lessons: Annotated[list[Lesson], PlanningEntityCollectionProperty]
+ ... rooms: Annotated[list[Room], ProblemFactCollectionProperty, ValueRangeProvider]
+ ... timeslots: Annotated[list[Timeslot], ProblemFactCollectionProperty, ValueRangeProvider]
+ ... configuration: Annotated[TimetablingConstraintConfiguration, ConstraintConfigurationProvider]
+ ... score: Annotated[HardSoftScore, PlanningScore]
"""
ensure_init()
from jpyinterpreter import add_class_annotation
@@ -340,6 +745,24 @@ def __init__(self, a_list):
def constraint_configuration(constraint_configuration_class: Type[Solution_]) -> Type[Solution_]:
+ """
+ Allows end users to change the constraint weights, by not hard coding them.
+ This decorator specifies that the class holds a number of ConstraintWeight annotated attributes.
+ That class must also have a weight for each of the constraints.
+
+ A `planning_solution` has at most one attribute annotated with `ConstraintConfigurationProvider`
+ with returns a type of the `ConstraintConfiguration` decorated class.
+
+ Examples
+ --------
+ >>> from timefold.solver.domain import ConstraintWeight, constraint_configuration
+ >>> from timefold.solver.score import HardSoftScore
+ >>> from typing import Annotated
+ >>>
+ >>> @constraint_configuration
+ ... class ConstraintConfiguration:
+ ... maximize_value: Annotated[HardSoftScore, ConstraintWeight('Maximize value')]
+ """
ensure_init()
from jpyinterpreter import add_class_annotation
from ai.timefold.solver.core.api.domain.constraintweight import (
@@ -348,54 +771,6 @@ def constraint_configuration(constraint_configuration_class: Type[Solution_]) ->
return out
-def problem_change(problem_change_class: Type['_ProblemChange']) -> \
- Type['_ProblemChange']:
- """A ProblemChange represents a change in 1 or more planning entities or problem facts of a PlanningSolution.
- Problem facts used by a Solver must not be changed while it is solving,
- but by scheduling this command to the Solver, you can change them when the time is right.
-
- Note that the Solver clones a PlanningSolution at will. Any change must be done on the problem facts and planning
- entities referenced by the PlanningSolution of the ProblemChangeDirector.
-
- The following methods must exist:
-
- def doChange(self, workingSolution: Solution_, problemChangeDirector: ProblemChangeDirector)
-
- :type problem_change_class: '_ProblemChange'
- :rtype: Type
- """
- ensure_init()
- from ai.timefold.solver.core.api.solver.change import ProblemChange
- if not callable(getattr(problem_change_class, 'doChange', None)):
- raise ValueError(f'@problem_change annotated class ({problem_change_class}) does not have required method '
- f'doChange(self, solution, problem_change_director).')
-
- class_doChange = getattr(problem_change_class, 'doChange', None)
-
- def wrapper_doChange(self, solution, problem_change_director):
- run_id = id(problem_change_director)
- solution.forceUpdate()
-
- reference_map = solution.get__timefold_reference_map()
- python_setter = solution._timefoldPythonSetter
-
- problem_change_director._set_instance_map(run_id, solution.get__timefold_reference_map())
- problem_change_director._set_update_function(run_id, solution._timefoldPythonSetter)
-
- class_doChange(self, solution, problem_change_director)
-
- problem_change_director._unset_instance_map(run_id)
- problem_change_director._unset_update_function(run_id)
-
- reference_map.clear()
- getattr(solution, "$setFields")(solution.get__timefold_id(), id(solution.get__timefold_id()), reference_map,
- python_setter)
-
- setattr(problem_change_class, 'doChange', JOverride()(wrapper_doChange))
- out = jpype.JImplements(ProblemChange)(problem_change_class)
- return out
-
-
__all__ = ['PlanningId', 'PlanningScore', 'PlanningPin', 'PlanningVariable',
'PlanningListVariable', 'ShadowVariable',
'PiggybackShadowVariable',
@@ -405,5 +780,4 @@ def wrapper_doChange(self, solution, problem_change_director):
'PlanningEntityProperty', 'PlanningEntityCollectionProperty',
'ValueRangeProvider', 'DeepPlanningClone', 'ConstraintConfigurationProvider',
'ConstraintWeight',
- 'planning_entity', 'planning_solution', 'constraint_configuration',
- 'problem_change']
+ 'planning_entity', 'planning_solution', 'constraint_configuration']
diff --git a/timefold-solver-python-core/src/main/python/domain/_value_range.py b/timefold-solver-python-core/src/main/python/domain/_value_range.py
index d23ec70..f328b2d 100644
--- a/timefold-solver-python-core/src/main/python/domain/_value_range.py
+++ b/timefold-solver-python-core/src/main/python/domain/_value_range.py
@@ -2,14 +2,44 @@
from typing import TYPE_CHECKING
from decimal import Decimal
if TYPE_CHECKING:
- from ai.timefold.solver.core.api.domain.valuerange import CountableValueRange
+ class CountableValueRange:
+ """
+ A set of a values for a PlanningVariable.
+ These values might be stored in memory as a collection (usually a `list` or `set`),
+ but if the values are numbers,
+ they can also be stored in memory by their bounds to use less memory and provide more opportunities.
+ It always has a discrete (as in non-continuous) range.
+ """
+ ...
class ValueRangeFactory:
+ """
+ Factory for `CountableValueRange`.
+ """
# Return cannot be typed, since CountableValueRange does not exist in the globals dict
# since it is loaded lazily (to not start the JVM prematurely)
@staticmethod
def create_int_value_range(start: int, end: int, step: int = None):
+ """
+ Build a `CountableValueRange` of all `int` values between two bounds.
+
+ Parameters
+ ----------
+ start : int
+ The inclusive lower bound of the range.
+ end : int
+ The exclusive upper bound of the range.
+ step : int, optional
+ The step of the range, defaults to ``1``.
+
+ Examples
+ --------
+ >>> ValueRangeFactory.create_int_value_range(1, 10)
+ CountableValueRange([1, 2, 3, 4, 5, 6, 7, 8, 9])
+ >>> ValueRangeFactory.create_int_value_range(1, 10, 2)
+ CountableValueRange([1, 3, 5, 7, 9])
+ """
ensure_init()
import jpype.imports
from ai.timefold.solver.python import PythonValueRangeFactory
@@ -22,6 +52,26 @@ def create_int_value_range(start: int, end: int, step: int = None):
@staticmethod
def create_float_value_range(start: Decimal, end: Decimal, step: Decimal = None):
+ """
+ Build a `CountableValueRange` of all `Decimal` values (of a specific scale) between two bounds.
+
+ Parameters
+ ----------
+ start : Decimal
+ The inclusive lower bound of the range.
+ end : Decimal
+ The exclusive upper bound of the range.
+ step : Decimal, optional
+ The step of the range, defaults to the lowest positive number
+ with the same scale as start.
+
+ Examples
+ --------
+ >>> ValueRangeFactory.create_float_value_range(Decimal('1.0'), Decimal('2.0'))
+ CountableValueRange([1.0, 1.1, 1.2, 1.3, 1.4, 1.5, 1.6, 1.7, 1.8, 1.9])
+ >>> ValueRangeFactory.create_float_value_range(Decimal('1.0'), Decimal('2.0'), Decimal('0.2'))
+ CountableValueRange([1.0, 1.2, 1.4, 1.6, 1.8])
+ """
ensure_init()
import jpype.imports
from ai.timefold.solver.python import PythonValueRangeFactory
@@ -34,6 +84,14 @@ def create_float_value_range(start: Decimal, end: Decimal, step: Decimal = None)
@staticmethod
def create_bool_value_range():
+ """
+ Build a CountableValueRange of both boolean values.
+
+ Examples
+ --------
+ >>> ValueRangeFactory.create_bool_value_range()
+ CountableValueRange([True, False])
+ """
ensure_init()
import jpype.imports
from ai.timefold.solver.python import PythonValueRangeFactory
diff --git a/timefold-solver-python-core/src/main/python/domain/_variable_listener.py b/timefold-solver-python-core/src/main/python/domain/_variable_listener.py
index 3d8dfc4..525e331 100644
--- a/timefold-solver-python-core/src/main/python/domain/_variable_listener.py
+++ b/timefold-solver-python-core/src/main/python/domain/_variable_listener.py
@@ -11,6 +11,21 @@
@add_java_interface('ai.timefold.solver.core.api.domain.variable.VariableListener')
class VariableListener:
+ """
+ A listener sourced on a basic PlanningVariable.
+
+ Changes shadow variables when a source basic planning variable changes.
+ The source variable can be either a genuine or a shadow variable.
+
+ Important: it must only change the shadow variable(s) for which it's configured!
+ It should never change a genuine variable or a problem fact.
+ It can change its shadow variable(s) on multiple entity instances
+ (for example: an arrival_time change affects all trailing entities too).
+
+ It is recommended to keep implementations stateless.
+ If state must be implemented,
+ implementations may need to override the methods `reset_working_solution`, and `close`.
+ """
def after_entity_added(self, score_director: ScoreDirector, entity) -> None:
pass
diff --git a/timefold-solver-python-core/src/main/python/heuristic/__init__.py b/timefold-solver-python-core/src/main/python/heuristic/__init__.py
index 5dbe766..111ff85 100644
--- a/timefold-solver-python-core/src/main/python/heuristic/__init__.py
+++ b/timefold-solver-python-core/src/main/python/heuristic/__init__.py
@@ -1 +1,4 @@
+"""
+Classes and decorators to configure heuristics.
+"""
from ._nearby_selection import *
diff --git a/timefold-solver-python-core/src/main/python/heuristic/_nearby_selection.py b/timefold-solver-python-core/src/main/python/heuristic/_nearby_selection.py
index 92f5ac2..3227d7b 100644
--- a/timefold-solver-python-core/src/main/python/heuristic/_nearby_selection.py
+++ b/timefold-solver-python-core/src/main/python/heuristic/_nearby_selection.py
@@ -7,6 +7,22 @@
def nearby_distance_meter(distance_function: Callable[[Origin_, Destination_], float], /) \
-> Callable[[Origin_, Destination_], float]:
+ """
+ Decorate a function so it can act as a distance meter for nearby selection.
+
+ The function must has the signature ``(Origin_, Destination_) -> float``.
+
+ The function should measures the distance from the origin to the destination.
+ The distance can be in any unit, such a meters, foot, seconds or milliseconds.
+ For example, vehicle routing often uses driving time in seconds.
+
+ Distances can be asymmetrical:
+ the distance from an origin to a destination often differs from the distance from that destination to that origin.
+
+ Implementations are expected to be stateless.
+ The solver may choose to reuse instances.
+
+ """
ensure_init()
from jpyinterpreter import translate_python_bytecode_to_java_bytecode, generate_proxy_class_for_translated_function
from ai.timefold.solver.core.impl.heuristic.selector.common.nearby import NearbyDistanceMeter # noqa
diff --git a/timefold-solver-python-core/src/main/python/score/__init__.py b/timefold-solver-python-core/src/main/python/score/__init__.py
index 713b1cd..faf2625 100644
--- a/timefold-solver-python-core/src/main/python/score/__init__.py
+++ b/timefold-solver-python-core/src/main/python/score/__init__.py
@@ -1,7 +1,24 @@
+"""
+Classes and decorators used to define constraints.
+
+Examples
+--------
+>>> from timefold.solver.score import ConstraintFactory, Constraint, Joiners, HardSoftScore, constraint_provider
+>>> from domain import Lesson
+>>>
+>>> @constraint_provider
+... def timetabling_constraints(cf: ConstraintFactory) -> list[Constraint]:
+... return [
+... cf.for_each_unique_pair(Lesson,
+... Joiners.equal(lambda lesson: lesson.teacher),
+... Joiners.equal(lambda lesson: lesson.timeslot))
+... .penalize(HardSoftScore.ONE_HARD)
+... .as_constraint('Overlapping Timeslots')
+... ]
+"""
from ._annotations import *
from ._constraint_builder import *
from ._constraint_factory import *
-from ._constraint_match_total import *
from ._constraint_stream import *
from ._function_translator import *
from ._group_by import *
@@ -14,13 +31,50 @@
if _TYPE_CHECKING:
class Score:
+ """
+ A Score is result of the score function (AKA fitness function) on a single possible solution.
+ Implementations must be immutable.
+
+ Attributes
+ ----------
+ init_score : int
+ The init score is the negative of the number of uninitialized genuine planning variables.
+ If it's 0 (which it usually is),
+ the `planning_solution` is fully initialized and the score's str does not mention it.
+ For comparisons, it's even more important than the hard score:
+ if you don't want this behaviour, read about overconstrained planning in the reference manual.
+
+ is_feasible : bool
+ A `planning_solution` is feasible if it has no broken hard constraints and `is_solution_initialized` is
+ true. `SimpleScore` are always feasible, if their `init_score` is 0.
+
+ is_solution_initialized : bool
+ Checks if the `planning_solution` of this score was fully initialized when it was calculated.
+ True if `init_score` is 0.
+
+ See Also
+ --------
+ HardSoftScore
+ """
+ init_score: int
+ is_feasible: bool
+ is_solution_initialized: bool
...
class SimpleScore(Score):
+ """
+ This Score is based on one level of `int` constraints.
+ This class is immutable.
+
+ Attributes
+ ----------
+ score : int
+ The total of the broken negative constraints and fulfilled positive constraints.
+ Their weight is included in the total.
+ The score is usually a negative number because most use cases only have negative constraints.
+ """
ZERO: 'SimpleScore' = None
ONE: 'SimpleScore' = None
- init_score: int
- is_feasible: bool
score: int
@staticmethod
@@ -28,11 +82,30 @@ def of(score: int, /) -> 'SimpleScore':
...
class HardSoftScore(Score):
+ """
+ This Score is based on two levels of int constraints: hard and soft.
+ Hard constraints have priority over soft constraints.
+ Hard constraints determine feasibility.
+
+ This class is immutable.
+
+ Attributes
+ ----------
+ hard_score : int
+ The total of the broken negative hard constraints and fulfilled positive hard constraints.
+ Their weight is included in the total.
+ The hard score is usually a negative number because most use cases only have negative constraints.
+
+ soft_score : int
+ The total of the broken negative soft constraints and fulfilled positive soft constraints.
+ Their weight is included in the total.
+ The soft score is usually a negative number because most use cases only have negative constraints.
+
+ In a normal score comparison, the soft score is irrelevant if the two scores don't have the same hard score.
+ """
ZERO: 'HardSoftScore' = None
ONE_HARD: 'HardSoftScore' = None
ONE_SOFT: 'HardSoftScore' = None
- init_score: int
- is_feasible: bool
hard_score: int
soft_score: int
@@ -42,12 +115,41 @@ def of(hard_score: int, soft_score: int, /) -> 'HardSoftScore':
class HardMediumSoftScore(Score):
+ """
+ This Score is based on three levels of int constraints: hard, medium and soft.
+ Hard constraints have priority over medium constraints.
+ Medium constraints have priority over soft constraints.
+ Hard constraints determine feasibility.
+
+ This class is immutable.
+
+ Attributes
+ ----------
+ hard_score : int
+ The total of the broken negative hard constraints and fulfilled positive hard constraints.
+ Their weight is included in the total.
+ The hard score is usually a negative number because most use cases only have negative constraints.
+
+ medium_score : int
+ The total of the broken negative medium constraints and fulfilled positive medium constraints.
+ Their weight is included in the total.
+ The medium score is usually a negative number because most use cases only have negative constraints.
+
+ In a normal score comparison,
+ the medium score is irrelevant if the two scores don't have the same hard score.
+
+ soft_score : int
+ The total of the broken negative soft constraints and fulfilled positive soft constraints.
+ Their weight is included in the total.
+ The soft score is usually a negative number because most use cases only have negative constraints.
+
+ In a normal score comparison,
+ the soft score is irrelevant if the two scores don't have the same hard and medium score.
+ """
ZERO: 'HardMediumSoftScore' = None
ONE_HARD: 'HardMediumSoftScore' = None
ONE_MEDIUM: 'HardMediumSoftScore' = None
ONE_SOFT: 'HardMediumSoftScore' = None
- init_score: int
- is_feasible: bool
hard_score: int
medium_score: int
soft_score: int
@@ -57,8 +159,20 @@ def of(self, hard_score: int, medium_score: int, soft_score: int, /) -> 'HardMed
...
class BendableScore(Score):
- init_score: int
- is_feasible: bool
+ """
+ This Score is based on n levels of int constraints.
+ The number of levels is bendable at configuration time.
+
+ This class is immutable.
+
+ Attributes
+ ----------
+ hard_scores : list[int]
+ A list of hard scores, with earlier hard scores having higher priority than later ones.
+
+ soft_scores : list[int]
+ A list of soft scores, with earlier soft scores having higher priority than later ones
+ """
hard_scores: list[int]
soft_scores: list[int]
diff --git a/timefold-solver-python-core/src/main/python/score/_annotations.py b/timefold-solver-python-core/src/main/python/score/_annotations.py
index 6ec72fd..0ef6588 100644
--- a/timefold-solver-python-core/src/main/python/score/_annotations.py
+++ b/timefold-solver-python-core/src/main/python/score/_annotations.py
@@ -11,6 +11,36 @@
def constraint_provider(constraint_provider_function: Callable[[ConstraintFactory], list[Constraint]], /) \
-> Callable[[ConstraintFactory], list[Constraint]]:
+ """
+ A decorator used to convert a function into a constraint provider.
+ Used by Constraint Streams' Score calculation.
+ An implementation must be stateless in order to facilitate
+ building a single set of constraints independent of potentially changing constraint weights.
+
+ The function must have the signature ``ConstraintFactory -> list[Constraint]``.
+
+ Examples
+ --------
+ >>> from timefold.solver.score import ConstraintFactory, Constraint, Joiners, HardSoftScore, constraint_provider
+ >>> from domain import Lesson
+ >>>
+ >>> @constraint_provider
+ ... def timetabling_constraints(cf: ConstraintFactory) -> list[Constraint]:
+ ... return [
+ ... cf.for_each_unique_pair(Lesson,
+ ... Joiners.equal(lambda lesson: lesson.teacher),
+ ... Joiners.equal(lambda lesson: lesson.timeslot))
+ ... .penalize(HardSoftScore.ONE_HARD)
+ ... .as_constraint('Overlapping Timeslots')
+ ... ]
+
+ See Also
+ --------
+ Joiners
+ ConstraintCollectors
+ ConstraintFactory
+ UniConstraintStream
+ """
ensure_init()
def constraint_provider_wrapper(function):
@@ -26,14 +56,29 @@ def wrapped_constraint_provider(constraint_factory):
def easy_score_calculator(easy_score_calculator_function: Callable[[Solution_], 'Score']) -> \
Callable[[Solution_], 'Score']:
- """Used for easy python Score calculation. This is non-incremental calculation, which is slow.
-
- The function takes a single parameter, the Solution, and
- must return a Score compatible with the Solution Score Type.
+ """
+ Used for easy Python `Score` calculation.
+ This is non-incremental calculation, which is slow.
An implementation must be stateless.
- :type easy_score_calculator_function: Callable[[Solution_], '_Score']
- :rtype: Callable[[Solution_], '_Score']
+ The function must have the signature ``Solution_ -> Score``.
+
+ Examples
+ --------
+ >>> from timefold.solver.score import SimpleScore, easy_score_calculator
+ >>> from domain import Timetable
+ >>>
+ >>> @easy_score_calculator
+ ... def timetabling_constraints(timetable: Timetable) -> SimpleScore:
+ ... total_score = 0
+ ...
+ ... for lesson_1 in timetable.lessons:
+ ... for lesson_2 in timetable.lessons:
+ ... if lesson_1.teacher == lesson_2.teacher and lesson_1.timeslot == lesson_2.timeslot:
+ ... total_score -= 1
+ ...
+ ... return SimpleScore.of(total_score)
+
"""
ensure_init()
from jpyinterpreter import translate_python_bytecode_to_java_bytecode, generate_proxy_class_for_translated_function
diff --git a/timefold-solver-python-core/src/main/python/score/_constraint_builder.py b/timefold-solver-python-core/src/main/python/score/_constraint_builder.py
index 29f1763..724fe96 100644
--- a/timefold-solver-python-core/src/main/python/score/_constraint_builder.py
+++ b/timefold-solver-python-core/src/main/python/score/_constraint_builder.py
@@ -18,10 +18,24 @@
class Constraint:
+ """
+ This represents a single constraint in the ConstraintStream API that impacts the Score.
+ It is defined in a function decorated by `constraint_provider` by calling `ConstraintFactory.for_each`.
+ """
...
class UniConstraintBuilder(Generic[A, ScoreType]):
+ """
+ Used to build a `Constraint` out of a `UniConstraintStream`, applying optional configuration.
+ To build the constraint, use one of the terminal operations, such as `as_constraint`.
+
+ Unless `justify_with` is called, the default justification mapping will be used.
+ The function takes the input arguments and score and converts them to a `DefaultConstraintJustification`.
+
+ Unless `indict_with` is called, the default indicted objects' mapping will be used.
+ The function takes the input arguments and converts them into a `list`.
+ """
delegate: '_JavaUniConstraintBuilder[A, ScoreType]'
a_type: Type[A]
@@ -31,16 +45,66 @@ def __init__(self, delegate: '_JavaUniConstraintBuilder[A, ScoreType]',
self.a_type = a_type
def indict_with(self, indictment_function: Callable[[A], Collection]) -> 'UniConstraintBuilder[A, ScoreType]':
+ """
+ Sets a custom function to mark any object returned by it as responsible for causing the constraint to match.
+ Each object in the collection
+ returned by this function will become an `Indictment` and be available as a key in
+ `ScoreExplanation.indictment_map`.
+
+ Parameters
+ ----------
+ indictment_function : Callable[[A], Collection]
+ the function that returns the indicted objects.
+
+ Returns
+ -------
+ UniConstraintBuilder
+ this `UniConstraintBuilder`.
+ """
return UniConstraintBuilder(self.delegate.indictWith(
function_cast(indictment_function, self.a_type)), self.a_type)
def justify_with(self, justification_function: Callable[[A, ScoreType], 'score_api.ConstraintJustification']) -> \
'UniConstraintBuilder[A, ScoreType]':
+ """
+ Sets a custom function to apply on a constraint match to justify it.
+ That function must not return a `Collection`,
+ else a RuntimeError will be raised during score calculation.
+
+ Parameters
+ ----------
+ justification_function : Callable[[A, ScoreType], ConstraintJustification]
+ the function that returns the justification.
+
+ Returns
+ -------
+ UniConstraintBuilder
+ this `UniConstraintBuilder`.
+ """
from ai.timefold.solver.core.api.score import Score
return UniConstraintBuilder(self.delegate.justifyWith(
function_cast(justification_function, self.a_type, Score)), self.a_type)
def as_constraint(self, constraint_package_or_name: str, constraint_name: str = None) -> Constraint:
+ """
+ Builds a Constraint from the constraint stream.
+
+ Parameters
+ ----------
+ constraint_package_or_name : str
+ If `constraint_name` is also provided, this is the constraint package name.
+ Otherwise, this is the constraint name.
+ The constraint package defaults to the module of the `planning_solution` class.
+
+ constraint_name : str, optional
+ The constraint name.
+ If present, `constraint_package_or_name` is treated as the constraint package name.
+
+ Returns
+ -------
+ Constraint
+ A `Constraint`.
+ """
if constraint_name is None:
return self.delegate.asConstraint(constraint_package_or_name)
else:
@@ -48,6 +112,16 @@ def as_constraint(self, constraint_package_or_name: str, constraint_name: str =
class BiConstraintBuilder(Generic[A, B, ScoreType]):
+ """
+ Used to build a `Constraint` out of a `UniConstraintStream`, applying optional configuration.
+ To build the constraint, use one of the terminal operations, such as `as_constraint`.
+
+ Unless `justify_with` is called, the default justification mapping will be used.
+ The function takes the input arguments and score and converts them to a `DefaultConstraintJustification`.
+
+ Unless `indict_with` is called, the default indicted objects' mapping will be used.
+ The function takes the input arguments and converts them into a `list`.
+ """
delegate: '_JavaBiConstraintBuilder[A, B, ScoreType]'
a_type: Type[A]
b_type: Type[B]
@@ -59,17 +133,67 @@ def __init__(self, delegate: '_JavaBiConstraintBuilder[A, B, ScoreType]',
self.b_type = b_type
def indict_with(self, indictment_function: Callable[[A, B], Collection]) -> 'BiConstraintBuilder[A, B, ScoreType]':
+ """
+ Sets a custom function to mark any object returned by it as responsible for causing the constraint to match.
+ Each object in the collection
+ returned by this function will become an `Indictment` and be available as a key in
+ `ScoreExplanation.indictment_map`.
+
+ Parameters
+ ----------
+ indictment_function : Callable[[A, B], Collection]
+ the function that returns the indicted objects.
+
+ Returns
+ -------
+ BiConstraintBuilder
+ this `BiConstraintBuilder`.
+ """
return BiConstraintBuilder(self.delegate.indictWith(
function_cast(indictment_function, self.a_type, self.b_type)), self.a_type, self.b_type)
def justify_with(self, justification_function: Callable[[A, B, ScoreType],
'score_api.ConstraintJustification']) -> \
'BiConstraintBuilder[A, B, ScoreType]':
+ """
+ Sets a custom function to apply on a constraint match to justify it.
+ That function must not return a `Collection`,
+ else a RuntimeError will be raised during score calculation.
+
+ Parameters
+ ----------
+ justification_function : Callable[[A, B, ScoreType], ConstraintJustification]
+ the function that returns the justification.
+
+ Returns
+ -------
+ BiConstraintBuilder
+ this `BiConstraintBuilder`.
+ """
from ai.timefold.solver.core.api.score import Score
return BiConstraintBuilder(self.delegate.justifyWith(
function_cast(justification_function, self.a_type, self.b_type, Score)), self.a_type, self.b_type)
def as_constraint(self, constraint_package_or_name: str, constraint_name: str = None) -> Constraint:
+ """
+ Builds a Constraint from the constraint stream.
+
+ Parameters
+ ----------
+ constraint_package_or_name : str
+ If `constraint_name` is also provided, this is the constraint package name.
+ Otherwise, this is the constraint name.
+ The constraint package defaults to the module of the `planning_solution` class.
+
+ constraint_name : str, optional
+ The constraint name.
+ If present, `constraint_package_or_name` is treated as the constraint package name.
+
+ Returns
+ -------
+ Constraint
+ A `Constraint`.
+ """
if constraint_name is None:
return self.delegate.asConstraint(constraint_package_or_name)
else:
@@ -77,6 +201,16 @@ def as_constraint(self, constraint_package_or_name: str, constraint_name: str =
class TriConstraintBuilder(Generic[A, B, C, ScoreType]):
+ """
+ Used to build a `Constraint` out of a `UniConstraintStream`, applying optional configuration.
+ To build the constraint, use one of the terminal operations, such as `as_constraint`.
+
+ Unless `justify_with` is called, the default justification mapping will be used.
+ The function takes the input arguments and score and converts them to a `DefaultConstraintJustification`.
+
+ Unless `indict_with` is called, the default indicted objects' mapping will be used.
+ The function takes the input arguments and converts them into a `list`.
+ """
delegate: '_JavaTriConstraintBuilder[A, B, C, ScoreType]'
a_type: Type[A]
b_type: Type[B]
@@ -91,6 +225,22 @@ def __init__(self, delegate: '_JavaTriConstraintBuilder[A, B, C, ScoreType]',
def indict_with(self, indictment_function: Callable[[A, B, C], Collection]) -> \
'TriConstraintBuilder[A, B, C, ScoreType]':
+ """
+ Sets a custom function to mark any object returned by it as responsible for causing the constraint to match.
+ Each object in the collection
+ returned by this function will become an `Indictment` and be available as a key in
+ `ScoreExplanation.indictment_map`.
+
+ Parameters
+ ----------
+ indictment_function : Callable[[A, B, C], Collection]
+ the function that returns the indicted objects.
+
+ Returns
+ -------
+ TriConstraintBuilder
+ this `TriConstraintBuilder`.
+ """
return TriConstraintBuilder(self.delegate.indictWith(
function_cast(indictment_function, self.a_type, self.b_type, self.c_type)), self.a_type, self.b_type,
self.c_type)
@@ -98,12 +248,46 @@ def indict_with(self, indictment_function: Callable[[A, B, C], Collection]) -> \
def justify_with(self, justification_function: Callable[[A, B, C, ScoreType],
'score_api.ConstraintJustification']) -> \
'TriConstraintBuilder[A, B, C, ScoreType]':
+ """
+ Sets a custom function to apply on a constraint match to justify it.
+ That function must not return a `Collection`,
+ else a RuntimeError will be raised during score calculation.
+
+ Parameters
+ ----------
+ justification_function : Callable[[A, B, C, ScoreType], ConstraintJustification]
+ the function that returns the justification.
+
+ Returns
+ -------
+ TriConstraintBuilder
+ this `TriConstraintBuilder`.
+ """
from ai.timefold.solver.core.api.score import Score
return TriConstraintBuilder(self.delegate.justifyWith(
function_cast(justification_function, self.a_type, self.b_type, self.c_type, Score)),
self.a_type, self.b_type, self.c_type)
def as_constraint(self, constraint_package_or_name: str, constraint_name: str = None) -> Constraint:
+ """
+ Builds a Constraint from the constraint stream.
+
+ Parameters
+ ----------
+ constraint_package_or_name : str
+ If `constraint_name` is also provided, this is the constraint package name.
+ Otherwise, this is the constraint name.
+ The constraint package defaults to the module of the `planning_solution` class.
+
+ constraint_name : str, optional
+ The constraint name.
+ If present, `constraint_package_or_name` is treated as the constraint package name.
+
+ Returns
+ -------
+ Constraint
+ A `Constraint`.
+ """
if constraint_name is None:
return self.delegate.asConstraint(constraint_package_or_name)
else:
@@ -111,6 +295,16 @@ def as_constraint(self, constraint_package_or_name: str, constraint_name: str =
class QuadConstraintBuilder(Generic[A, B, C, D, ScoreType]):
+ """
+ Used to build a `Constraint` out of a `UniConstraintStream`, applying optional configuration.
+ To build the constraint, use one of the terminal operations, such as `as_constraint`.
+
+ Unless `justify_with` is called, the default justification mapping will be used.
+ The function takes the input arguments and score and converts them to a `DefaultConstraintJustification`.
+
+ Unless `indict_with` is called, the default indicted objects' mapping will be used.
+ The function takes the input arguments and converts them into a `list`.
+ """
delegate: '_JavaQuadConstraintBuilder[A, B, C, D, ScoreType]'
a_type: Type[A]
b_type: Type[B]
@@ -127,6 +321,22 @@ def __init__(self, delegate: '_JavaQuadConstraintBuilder[A, B, C, D, ScoreType]'
def indict_with(self, indictment_function: Callable[[A, B, C, D], Collection]) -> \
'QuadConstraintBuilder[A, B, C, D, ScoreType]':
+ """
+ Sets a custom function to mark any object returned by it as responsible for causing the constraint to match.
+ Each object in the collection
+ returned by this function will become an `Indictment` and be available as a key in
+ `ScoreExplanation.indictment_map`.
+
+ Parameters
+ ----------
+ indictment_function : Callable[[A, B, C, D], Collection]
+ the function that returns the indicted objects.
+
+ Returns
+ -------
+ QuadConstraintBuilder
+ this `QuadConstraintBuilder`.
+ """
return QuadConstraintBuilder(self.delegate.indictWith(
function_cast(indictment_function, self.a_type, self.b_type, self.c_type, self.d_type)),
self.a_type, self.b_type, self.c_type, self.d_type)
@@ -134,12 +344,46 @@ def indict_with(self, indictment_function: Callable[[A, B, C, D], Collection]) -
def justify_with(self, justification_function: Callable[[A, B, C, D, ScoreType],
'score_api.ConstraintJustification']) \
-> 'QuadConstraintBuilder[A, B, C, D, ScoreType]':
+ """
+ Sets a custom function to apply on a constraint match to justify it.
+ That function must not return a `Collection`,
+ else a RuntimeError will be raised during score calculation.
+
+ Parameters
+ ----------
+ justification_function : Callable[[A, B, C, D, ScoreType], ConstraintJustification]
+ the function that returns the justification.
+
+ Returns
+ -------
+ QuadConstraintBuilder
+ this `QuadConstraintBuilder`.
+ """
from ai.timefold.solver.core.api.score import Score
return QuadConstraintBuilder(self.delegate.justifyWith(
function_cast(justification_function, self.a_type, self.b_type, self.c_type, self.d_type, Score)),
self.a_type, self.b_type, self.c_type, self.d_type)
def as_constraint(self, constraint_package_or_name: str, constraint_name: str = None) -> Constraint:
+ """
+ Builds a Constraint from the constraint stream.
+
+ Parameters
+ ----------
+ constraint_package_or_name : str
+ If `constraint_name` is also provided, this is the constraint package name.
+ Otherwise, this is the constraint name.
+ The constraint package defaults to the module of the `planning_solution` class.
+
+ constraint_name : str, optional
+ The constraint name.
+ If present, `constraint_package_or_name` is treated as the constraint package name.
+
+ Returns
+ -------
+ Constraint
+ A `Constraint`.
+ """
if constraint_name is None:
return self.delegate.asConstraint(constraint_package_or_name)
else:
diff --git a/timefold-solver-python-core/src/main/python/score/_constraint_factory.py b/timefold-solver-python-core/src/main/python/score/_constraint_factory.py
index 027d3fd..475e8fc 100644
--- a/timefold-solver-python-core/src/main/python/score/_constraint_factory.py
+++ b/timefold-solver-python-core/src/main/python/score/_constraint_factory.py
@@ -7,6 +7,10 @@
class ConstraintFactory:
+ """
+ The factory to create every ConstraintStream (for example with `for_each`)
+ which ends in a `Constraint` returned by a function decorated with `constraint_provider`.
+ """
delegate: '_JavaConstraintFactory'
A_ = TypeVar('A_')
B_ = TypeVar('B_')
@@ -18,32 +22,49 @@ def __init__(self, delegate: '_JavaConstraintFactory'):
self.delegate = delegate
def get_default_constraint_package(self) -> str:
- """This is ConstraintConfiguration.constraintPackage() if available,
- otherwise the module of the @constraint_provider function
-
- :return:
+ """
+ This is `constraint_configuration(constraint_package=...)` if available,
+ otherwise the module of the `constraint_provider` function.
"""
return self.delegate.getDefaultConstraintPackage()
def for_each(self, source_class: Type[A_]) -> 'UniConstraintStream[A_]':
- """Start a ConstraintStream of all instances of the source_class that are known as problem facts or
- planning entities.
-
- :param source_class:
-
- :return:
+ """
+ Start a ConstraintStream of all instances of the `source_class`
+ that are known as problem facts or planning entities.
+ If the `source_class` is a `planning_entity`,
+ then it is automatically filtered to only contain entities for which each genuine `PlanningVariable`
+ (of the `source_class` or a superclass thereof) has a non-None value.
+
+ If the `source_class` is a shadow entity (an entity without any genuine planning variables),
+ and if there exists a genuine `planning_entity` with a `PlanningListVariable` which accepts instances of this
+ shadow entity as values in that list, and if that list variable allows unassigned values, then this stream will
+ filter out all `source_class` instances which are not present in any instances of that list variable.
+ This is achieved in one of two ways:
+
+ - If the `source_class`
+ has `InverseRelationShadowVariable` field referencing instance of an entity with the list variable,
+ the value of that field will be used to determine if the value is assigned.
+ ``None`` in that field means the instance of `source_class` is unassigned.
+
+ - As fallback,
+ the value is considered assigned if there exists an instance of the entity
+ where its list variable contains the value.
+ This will perform significantly worse
+ and only exists so that using the `InverseRelationShadowVariable` can remain optional.
+ Adding the field is strongly recommended.
"""
source_class = get_class(source_class)
return UniConstraintStream(self.delegate.forEach(source_class), self.get_default_constraint_package(),
cast(Type['A_'], source_class))
def for_each_including_unassigned(self, source_class: Type[A_]) -> 'UniConstraintStream[A_]':
- """Start a ConstraintStream of all instances of the source_class that are known as problem facts or planning
- entities, without filtering of entities with unassigned planning variables.
-
- :param source_class:
-
- :return:
+ """
+ As defined by `for_each`,
+ but without any filtering of unassigned planning entities
+ (for ``PlanningVariable(allows_unassigned=True)``)
+ or shadow entities not assigned to any applicable list variable
+ (for ``PlanningListVariable(allows_unassigned_values=True)``).
"""
source_class = get_class(source_class)
return UniConstraintStream(self.delegate.forEachIncludingUnassigned(source_class),
@@ -52,14 +73,9 @@ def for_each_including_unassigned(self, source_class: Type[A_]) -> 'UniConstrain
def for_each_unique_pair(self, source_class: Type[A_], *joiners: 'BiJoiner[A_, A_]') -> \
'BiConstraintStream[A_, A_]':
- """Create a new BiConstraintStream for every unique combination of A and another A with a higher @planning_id
- that satisfies all specified joiners.
-
- :param source_class:
-
- :param joiners:
-
- :return:
+ """
+ Create a new `BiConstraintStream` for every unique combination of A and another A with a higher `PlanningId`
+ for which every `BiJoiner` is true (for the properties it extracts from both facts).
"""
source_class = get_class(source_class)
return BiConstraintStream(self.delegate.forEachUniquePair(source_class,
diff --git a/timefold-solver-python-core/src/main/python/score/_constraint_match_total.py b/timefold-solver-python-core/src/main/python/score/_constraint_match_total.py
deleted file mode 100644
index 6d0f89f..0000000
--- a/timefold-solver-python-core/src/main/python/score/_constraint_match_total.py
+++ /dev/null
@@ -1,113 +0,0 @@
-from typing import TYPE_CHECKING
-from jpype import JImplements, JOverride
-
-if TYPE_CHECKING:
- from ai.timefold.solver.core.api.score.constraint import ConstraintRef, ConstraintMatch, ConstraintMatchTotal
- from ai.timefold.solver.core.api.score import Score as _Score
-
-
-# Cannot import DefaultConstraintMatchTotal as it is in impl
-@JImplements('ai.timefold.solver.core.api.score.constraint.ConstraintMatchTotal', deferred=True)
-class DefaultConstraintMatchTotal:
- """
- A default implementation of ConstraintMatchTotal that can be used in a constraint match aware
- @incremental_score_calculator.
- """
- def __init__(self, constraint_package: str, constraint_name: str, constraint_weight: '_Score' = None):
- from ai.timefold.solver.core.api.score.constraint import ConstraintMatchTotal
- from java.util import LinkedHashSet
- self.constraint_package = constraint_package
- self.constraint_name = constraint_name
- self.constraint_id = ConstraintMatchTotal.composeConstraintId(constraint_package, constraint_name)
- self.constraint_weight = constraint_weight
- self.constraint_match_set = LinkedHashSet()
- if constraint_weight is not None:
- self.score = constraint_weight.zero()
- else:
- self.score = None
-
- @JOverride
- def getConstraintPackage(self):
- return self.constraint_package
-
- @JOverride
- def getConstraintName(self):
- return self.constraint_name
-
- @JOverride
- def getConstraintRef(self):
- return ConstraintRef.of(self.constraint_package, self.constraint_name)
-
- @JOverride
- def getConstraintWeight(self):
- return self.constraint_weight
-
- @JOverride
- def getConstraintMatchSet(self):
- return self.constraint_match_set
-
- @JOverride
- def getScore(self):
- return self.score
-
- @JOverride
- def getConstraintId(self):
- return self.constraint_id
-
- @JOverride
- def compareTo(self, other: 'DefaultConstraintMatchTotal'):
- if self.constraint_id == other.constraint_id:
- return 0
- elif self.constraint_id < other.constraint_id:
- return -1
- else:
- return 1
-
- def __lt__(self, other):
- return self.constraint_id < other.constraint_id
-
- def __gt__(self, other):
- return self.constraint_id > other.constraint_id
-
- @JOverride
- def equals(self, other):
- if self is other:
- return True
- elif isinstance(other, DefaultConstraintMatchTotal):
- return self.constraint_id == other.constraint_id
- else:
- return False
-
- def __eq__(self, other):
- return self.constraint_id == other.constraint_id
-
- @JOverride
- def hashCode(self):
- return hash(self.constraint_id)
-
- def __hash__(self):
- return hash(self.constraint_id)
-
- @JOverride
- def toString(self):
- return f'{self.constraint_id}={self.score}'
-
- def addConstraintMatch(self, justification_list: list, score: '_Score') -> 'ConstraintMatch':
- from ai.timefold.solver.core.api.score.constraint import ConstraintMatch
- from java.util import Arrays
- self.score = self.score.add(score) if self.score is not None else score
- wrapped_justification_list = Arrays.asList(justification_list)
- constraint_match = ConstraintMatch(self.constraint_package, self.constraint_name, wrapped_justification_list,
- score)
- self.constraint_match_set.add(constraint_match)
- return constraint_match
-
- def removeConstraintMatch(self, constraint_match: 'ConstraintMatch'):
- self.score = self.score.subtract(constraint_match.getScore())
- removed = self.constraint_match_set.remove(constraint_match)
- if not removed:
- raise ValueError(f'The ConstraintMatchTotal ({self}) could not remove the ConstraintMatch'
- f'({constraint_match}) from its constraint_match_set ({self.constraint_match_set}).')
-
-
-__all__ = ['DefaultConstraintMatchTotal']
diff --git a/timefold-solver-python-core/src/main/python/score/_constraint_stream.py b/timefold-solver-python-core/src/main/python/score/_constraint_stream.py
index 6c08192..d6395eb 100644
--- a/timefold-solver-python-core/src/main/python/score/_constraint_stream.py
+++ b/timefold-solver-python-core/src/main/python/score/_constraint_stream.py
@@ -23,6 +23,9 @@
class UniConstraintStream(Generic[A]):
+ """
+ A ConstraintStream that matches one fact.
+ """
delegate: '_JavaUniConstraintStream[A]'
package: str
a_type: Type[A]
@@ -39,14 +42,14 @@ def __init__(self, delegate: '_JavaUniConstraintStream[A]', package: str,
self.a_type = a_type
def get_constraint_factory(self):
+ """
+ The ConstraintFactory that build this.
+ """
return ConstraintFactory(self.delegate.getConstraintFactory())
def filter(self, predicate: Callable[[A], bool]) -> 'UniConstraintStream[A]':
- """Exhaustively test each fact against the predicate and match if the predicate returns True.
-
- :param predicate:
-
- :return:
+ """
+ Exhaustively test each fact against the predicate and match if the predicate returns ``True``.
"""
translated_predicate = predicate_cast(predicate, self.a_type)
return UniConstraintStream(self.delegate.filter(translated_predicate), self.package,
@@ -54,13 +57,8 @@ def filter(self, predicate: Callable[[A], bool]) -> 'UniConstraintStream[A]':
def join(self, unistream_or_type: Union['UniConstraintStream[B_]', Type[B_]], *joiners: 'BiJoiner[A, B_]') -> \
'BiConstraintStream[A,B_]':
- """Create a new BiConstraintStream for every combination of A and B that satisfy all specified joiners.
-
- :param unistream_or_type:
-
- :param joiners:
-
- :return:
+ """
+ Create a new `BiConstraintStream` for every combination of A and B that satisfy all specified joiners.
"""
b_type = None
if isinstance(unistream_or_type, UniConstraintStream):
@@ -75,13 +73,8 @@ def join(self, unistream_or_type: Union['UniConstraintStream[B_]', Type[B_]], *j
self.a_type, b_type)
def if_exists(self, item_type: Type[B_], *joiners: 'BiJoiner[A, B_]') -> 'UniConstraintStream[A]':
- """Create a new UniConstraintStream for every A where B exists that satisfy all specified joiners.
-
- :param item_type:
-
- :param joiners:
-
- :return:
+ """
+ Create a new UniConstraintStream for every A where B exists that satisfy all specified joiners.
"""
item_type = get_class(item_type)
return UniConstraintStream(self.delegate.ifExists(item_type,
@@ -90,13 +83,8 @@ def if_exists(self, item_type: Type[B_], *joiners: 'BiJoiner[A, B_]') -> 'UniCon
def if_exists_including_unassigned(self, item_type: Type[B_], *joiners: 'BiJoiner[A, B_]') -> \
'UniConstraintStream[A]':
- """Create a new UniConstraintStream for every A where B exists that satisfy all specified joiners.
-
- :param item_type:
-
- :param joiners:
-
- :return:
+ """
+ Create a new `UniConstraintStream` for every A where B exists that satisfy all specified joiners.
"""
item_type = get_class(item_type)
return UniConstraintStream(self.delegate.ifExistsIncludingUnassigned(item_type,
@@ -106,14 +94,9 @@ def if_exists_including_unassigned(self, item_type: Type[B_], *joiners: 'BiJoine
self.a_type)
def if_exists_other(self, item_type: Type[B_], *joiners: 'BiJoiner[A, B_]') -> 'UniConstraintStream[A]':
- """Create a new UniConstraintStream for every A, if another A exists that does not equal the first,
+ """
+ Create a new `UniConstraintStream` for every A, if another A exists that does not equal the first,
and for which all specified joiners are satisfied.
-
- :param item_type:
-
- :param joiners:
-
- :return:
"""
item_type = get_class(item_type)
return UniConstraintStream(self.delegate.ifExistsOther(cast(Type['A_'], item_type),
@@ -124,6 +107,11 @@ def if_exists_other(self, item_type: Type[B_], *joiners: 'BiJoiner[A, B_]') -> '
def if_exists_other_including_unassigned(self, item_type: Type, *joiners: 'BiJoiner') -> \
'UniConstraintStream':
+ """
+ Create a new UniConstraintStream for every A, if another A exists that does not equal the first.
+ For classes decorated with `planning_entity`, this method also includes entities with ``None`` variables,
+ or entities that are not assigned to any list variable.
+ """
item_type = get_class(item_type)
return UniConstraintStream(self.delegate.ifExistsOtherIncludingUnassigned(cast(Type['A_'], item_type),
extract_joiners(joiners,
@@ -134,14 +122,9 @@ def if_exists_other_including_unassigned(self, item_type: Type, *joiners: 'BiJoi
self.a_type)
def if_not_exists(self, item_type: Type[B_], *joiners: 'BiJoiner[A, B_]') -> 'UniConstraintStream[A]':
- """Create a new UniConstraintStream for every A where there does not exist a B where all specified joiners
+ """
+ Create a new `UniConstraintStream` for every A where there does not exist a B where all specified joiners
are satisfied.
-
- :param item_type:
-
- :param joiners:
-
- :return:
"""
item_type = get_class(item_type)
return UniConstraintStream(self.delegate.ifNotExists(item_type, extract_joiners(joiners, self.a_type,
@@ -150,14 +133,9 @@ def if_not_exists(self, item_type: Type[B_], *joiners: 'BiJoiner[A, B_]') -> 'Un
def if_not_exists_including_unassigned(self, item_type: Type[B_], *joiners: 'BiJoiner[A, B_]') -> \
'UniConstraintStream[A]':
- """Create a new UniConstraintStream for every A where there does not exist a B where all specified joiners are
+ """
+ Create a new `UniConstraintStream` for every A where there does not exist a B where all specified joiners are
satisfied.
-
- :param item_type:
-
- :param joiners:
-
- :return:
"""
item_type = get_class(item_type)
return UniConstraintStream(self.delegate.ifNotExistsIncludingUnassigned(item_type,
@@ -170,14 +148,9 @@ def if_not_exists_including_unassigned(self, item_type: Type[B_], *joiners: 'BiJ
def if_not_exists_other(self, item_type: Type[B_], *joiners: 'BiJoiner[A, B_]') -> \
'UniConstraintStream[A]':
- """Create a new UniConstraintStream for every A where there does not exist a different A where all specified
+ """
+ Create a new `UniConstraintStream` for every A where there does not exist a different A where all specified
joiners are satisfied.
-
- :param item_type:
-
- :param joiners:
-
- :return:
"""
item_type = get_class(item_type)
return UniConstraintStream(self.delegate.ifNotExistsOther(cast(Type['A_'], item_type),
@@ -190,14 +163,9 @@ def if_not_exists_other(self, item_type: Type[B_], *joiners: 'BiJoiner[A, B_]')
def if_not_exists_other_including_unassigned(self, item_type: Type[B_], *joiners: 'BiJoiner[A, B_]') -> \
'UniConstraintStream[A]':
- """Create a new UniConstraintStream for every A where there does not exist a different A where all specified
+ """
+ Create a new `UniConstraintStream` for every A where there does not exist a different A where all specified
joiners are satisfied.
-
- :param item_type:
-
- :param joiners:
-
- :return:
"""
item_type = get_class(item_type)
return UniConstraintStream(self.delegate.ifNotExistsOtherIncludingUnassigned(cast(Type['A_'], item_type),
@@ -283,7 +251,8 @@ def group_by(self, first_collector: 'UniConstraintCollector[A, Any, A_]',
...
def group_by(self, *args):
- """Collect items into groups using the group_key_function(s) and optionally aggregate the group's items into a
+ """
+ Collect items into groups using the group_key_function(s) and optionally aggregate the group's items into a
result.
The syntax of group_by is zero to four group_key functions, followed by zero to four collectors. At most
@@ -292,44 +261,43 @@ def group_by(self, *args):
If no group_key function is passed to group_by, all items in the stream are aggregated into a single result
by the passed constraint collectors.
- Examples:
-
- - # count the items in this stream; returns Uni[int]
-
- group_by(ConstraintCollectors.count())
+ Returns
+ -------
+ UniConstraintStream | BiConstraintStream | TriConstraintStream | QuadConstraintStream
+ The type of stream returned depends on the number of arguments passed:
- - # count the number of shifts each employee has; returns Bi[Employee]
+ - 1 -> UniConstraintStream
- group_by(lambda shift: shift.employee, ConstraintCollectors.count())
+ - 2 -> BiConstraintStream
- - # count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
+ - 3 -> TriConstraintStream
- group_by(lambda shift: shift.employee, lambda shift: shift.date, ConstraintCollectors.count())
+ - 4 -> QuadConstraintStream
- - # count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
+ Examples
+ --------
- group_by(lambda shift: shift.employee, lambda shift: shift.date, ConstraintCollectors.count())
+ Count the items in this stream; returns Uni[int]
- - # get the dates of the first and last shift of each employee; returns Tri[Employee, datetime.date,
- datetime.date]
+ >>> group_by(ConstraintCollectors.count())
- group_by(lambda shift: shift.employee,
- ConstraintCollectors.min(lambda shift: shift.date)
- ConstraintCollectors.max(lambda shift: shift.date))
+ Count the number of shifts each employee has; returns Bi[Employee]
- The type of stream returned depends on the number of arguments passed:
+ >>> group_by(lambda shift: shift.employee, ConstraintCollectors.count())
- - 1 -> UniConstraintStream
+ Count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
- - 2 -> BiConstraintStream
+ >>> group_by(lambda shift: shift.employee, lambda shift: shift.date, ConstraintCollectors.count())
- - 3 -> TriConstraintStream
+ Count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
- - 4 -> QuadConstraintStream
+ >>> group_by(lambda shift: shift.employee, lambda shift: shift.date, ConstraintCollectors.count())
- :param args:
+ Get the dates of the first and last shift of each employee; returns Tri[Employee, datetime.date, datetime.date]
- :return:
+ >>> group_by(lambda shift: shift.employee,
+ ... ConstraintCollectors.min(lambda shift: shift.date)
+ ... ConstraintCollectors.max(lambda shift: shift.date))
"""
return perform_group_by(self.delegate, self.package, args, self.a_type)
@@ -354,11 +322,8 @@ def map(self, mapping_function: Callable[[A], A_], mapping_function2: Callable[[
...
def map(self, *mapping_functions):
- """Transforms the stream in such a way that tuples are remapped using the given function.
-
- :param mapping_functions:
-
- :return:
+ """
+ Transforms the stream in such a way that tuples are remapped using the given function.
"""
if len(mapping_functions) == 0:
raise ValueError(f'At least one mapping function is required for map.')
@@ -406,8 +371,6 @@ def expand(self, *mapping_functions):
which only increases stream cardinality and can not introduce duplicate tuples.
It enables you to add extra facts to each tuple in a constraint stream by applying a mapping function to it.
This is useful in situations where an expensive computations needs to be cached for use later in the stream.
- :param mapping_functions:
- :return:
"""
if len(mapping_functions) == 0:
raise ValueError(f'At least one mapping function is required for expand.')
@@ -433,11 +396,8 @@ def expand(self, *mapping_functions):
raise RuntimeError(f'Impossible state: missing case for {len(mapping_functions)}.')
def flatten_last(self, flattening_function: Callable[[A], A_]) -> 'UniConstraintStream[A_]':
- """Takes each tuple and applies a mapping on it, which turns the tuple into an Iterable.
-
- :param flattening_function:
-
- :return:
+ """
+ Takes each tuple and applies a mapping on it, which turns the tuple into an Iterable.
"""
translated_function = function_cast(flattening_function, self.a_type)
return UniConstraintStream(self.delegate.flattenLast(translated_function), self.package,
@@ -445,9 +405,8 @@ def flatten_last(self, flattening_function: Callable[[A], A_]) -> 'UniConstraint
JClass('java.lang.Object'))
def distinct(self) -> 'UniConstraintStream[A]':
- """Transforms the stream in such a way that all the tuples going through it are distinct.
-
- :return:
+ """
+ Transforms the stream in such a way that all the tuples going through it are distinct.
"""
return UniConstraintStream(self.delegate.distinct(), self.package, self.a_type)
@@ -476,8 +435,6 @@ def concat(self, other):
If the two constraint concatenating streams share tuples, which happens e.g.
when they come from the same source of data, the tuples will be repeated downstream.
If this is undesired, use the distinct building block.
- :param other:
- :return:
"""
if isinstance(other, UniConstraintStream):
return UniConstraintStream(self.delegate.concat(other.delegate), self.package,
@@ -499,6 +456,24 @@ def concat(self, other):
def penalize(self, constraint_weight: ScoreType, match_weigher: Callable[[A], int] = None) -> \
'UniConstraintBuilder[A, ScoreType]':
+ """
+ Applies a negative Score impact, subtracting the constraint_weight multiplied by the match weight,
+ and returns a builder to apply optional constraint properties.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ UniConstraintBuilder
+ a `UniConstraintBuilder`
+ """
if match_weigher is None:
return UniConstraintBuilder(self.delegate.penalize(constraint_weight), self.a_type)
else:
@@ -508,6 +483,24 @@ def penalize(self, constraint_weight: ScoreType, match_weigher: Callable[[A], in
def reward(self, constraint_weight: ScoreType, match_weigher: Callable[[A], int] = None) -> \
'UniConstraintBuilder[A, ScoreType]':
+ """
+ Applies a positive Score impact, adding the constraint_weight multiplied by the match weight,
+ and returns a builder to apply optional constraint properties.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ UniConstraintBuilder
+ a `UniConstraintBuilder`
+ """
if match_weigher is None:
return UniConstraintBuilder(self.delegate.reward(constraint_weight), self.a_type)
else:
@@ -517,6 +510,25 @@ def reward(self, constraint_weight: ScoreType, match_weigher: Callable[[A], int]
def impact(self, constraint_weight: ScoreType, match_weigher: Callable[[A], int] = None) -> \
'UniConstraintBuilder[A, ScoreType]':
+ """
+ Positively or negatively impacts the `Score` by `constraint_weight` multiplied by match weight for each match
+ and returns a builder to apply optional constraint properties.
+ Use `penalize` or `reward` instead, unless this constraint can both have positive and negative weights.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ UniConstraintBuilder
+ a `UniConstraintBuilder`
+ """
if match_weigher is None:
return UniConstraintBuilder(self.delegate.impact(constraint_weight), self.a_type)
else:
@@ -526,6 +538,25 @@ def impact(self, constraint_weight: ScoreType, match_weigher: Callable[[A], int]
def penalize_configurable(self, match_weigher: Callable[[A], int] = None) -> \
'UniConstraintBuilder[A, ScoreType]':
+ """
+ Negatively impacts the Score, subtracting the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+
+ Parameters
+ ----------
+ match_weigher : Callable[[A], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ UniConstraintBuilder
+ a `UniConstraintBuilder`
+ """
if match_weigher is None:
return UniConstraintBuilder(self.delegate.penalizeConfigurable(), self.a_type)
else:
@@ -535,6 +566,25 @@ def penalize_configurable(self, match_weigher: Callable[[A], int] = None) -> \
def reward_configurable(self, match_weigher: Callable[[A], int] = None) -> \
'UniConstraintBuilder[A, ScoreType]':
+ """
+ Positively impacts the Score, adding the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+
+ Parameters
+ ----------
+ match_weigher : Callable[[A], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ UniConstraintBuilder
+ a `UniConstraintBuilder`
+ """
if match_weigher is None:
return UniConstraintBuilder(self.delegate.rewardConfigurable(), self.a_type)
else:
@@ -544,6 +594,25 @@ def reward_configurable(self, match_weigher: Callable[[A], int] = None) -> \
def impact_configurable(self, match_weigher: Callable[[A], int] = None) -> \
'UniConstraintBuilder[A, ScoreType]':
+ """
+ Positively or negatively impacts the Score, adding the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+
+ Parameters
+ ----------
+ match_weigher : Callable[[A], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ UniConstraintBuilder
+ a `UniConstraintBuilder`
+ """
if match_weigher is None:
return UniConstraintBuilder(self.delegate.impactConfigurable(), self.a_type)
else:
@@ -553,6 +622,9 @@ def impact_configurable(self, match_weigher: Callable[[A], int] = None) -> \
class BiConstraintStream(Generic[A, B]):
+ """
+ A ConstraintStream that matches two facts.
+ """
delegate: '_JavaBiConstraintStream[A,B]'
package: str
a_type: Type[A]
@@ -571,14 +643,14 @@ def __init__(self, delegate: '_JavaBiConstraintStream[A,B]', package: str,
self.b_type = b_type
def get_constraint_factory(self):
+ """
+ The ConstraintFactory that build this.
+ """
return ConstraintFactory(self.delegate.getConstraintFactory())
def filter(self, predicate: Callable[[A, B], bool]) -> 'BiConstraintStream[A,B]':
- """Exhaustively test each fact against the predicate and match if the predicate returns True.
-
- :param predicate:
-
- :return:
+ """
+ Exhaustively test each fact against the predicate and match if the predicate returns ``True``.
"""
translated_predicate = predicate_cast(predicate, self.a_type, self.b_type)
return BiConstraintStream(self.delegate.filter(translated_predicate), self.package,
@@ -588,13 +660,8 @@ def filter(self, predicate: Callable[[A, B], bool]) -> 'BiConstraintStream[A,B]'
def join(self, unistream_or_type: Union[UniConstraintStream[C_], Type[C_]],
*joiners: 'TriJoiner[A,B,C_]') -> 'TriConstraintStream[A,B,C_]':
- """Create a new TriConstraintStream for every combination of A, B and C that satisfy all specified joiners.
-
- :param unistream_or_type:
-
- :param joiners:
-
- :return:
+ """
+ Create a new `TriConstraintStream` for every combination of A, B and C that satisfy all specified joiners.
"""
c_type = None
if isinstance(unistream_or_type, UniConstraintStream):
@@ -610,13 +677,8 @@ def join(self, unistream_or_type: Union[UniConstraintStream[C_], Type[C_]],
self.a_type, self.b_type, c_type)
def if_exists(self, item_type: Type[C_], *joiners: 'TriJoiner[A, B, C_]') -> 'BiConstraintStream[A,B]':
- """Create a new BiConstraintStream for every A, B where C exists that satisfy all specified joiners.
-
- :param item_type:
-
- :param joiners:
-
- :return:
+ """
+ Create a new `BiConstraintStream` for every A, B where C exists that satisfy all specified joiners.
"""
item_type = get_class(item_type)
return BiConstraintStream(self.delegate.ifExists(item_type,
@@ -627,13 +689,8 @@ def if_exists(self, item_type: Type[C_], *joiners: 'TriJoiner[A, B, C_]') -> 'Bi
def if_exists_including_unassigned(self, item_type: Type[C_], *joiners: 'TriJoiner[A, B, C_]') -> \
'BiConstraintStream[A,B]':
- """Create a new BiConstraintStream for every A, B where C exists that satisfy all specified joiners.
-
- :param item_type:
-
- :param joiners:
-
- :return:
+ """
+ Create a new `BiConstraintStream` for every A, B where C exists that satisfy all specified joiners.
"""
item_type = get_class(item_type)
return BiConstraintStream(self.delegate.ifExistsIncludingUnassigned(item_type, extract_joiners(joiners,
@@ -646,14 +703,9 @@ def if_exists_including_unassigned(self, item_type: Type[C_], *joiners: 'TriJoin
def if_not_exists(self, item_type: Type[C_], *joiners: 'TriJoiner[A, B, C_]') -> \
'BiConstraintStream[A,B]':
- """Create a new BiConstraintStream for every A, B, where there does not exist a C where all specified joiners
+ """
+ Create a new `BiConstraintStream` for every A, B, where there does not exist a C where all specified joiners
are satisfied.
-
- :param item_type:
-
- :param joiners:
-
- :return:
"""
item_type = get_class(item_type)
return BiConstraintStream(self.delegate.ifNotExists(item_type, extract_joiners(joiners, self.a_type,
@@ -663,14 +715,9 @@ def if_not_exists(self, item_type: Type[C_], *joiners: 'TriJoiner[A, B, C_]') ->
def if_not_exists_including_unassigned(self, item_type: Type[C_], *joiners: 'TriJoiner[A, B, C_]') -> \
'BiConstraintStream[A,B]':
- """Create a new BiConstraintStream for every A, B, where there does not exist a C where all specified joiners
+ """
+ Create a new `BiConstraintStream` for every A, B, where there does not exist a C where all specified joiners
are satisfied.
-
- :param item_type:
-
- :param joiners:
-
- :return:
"""
item_type = get_class(item_type)
return BiConstraintStream(self.delegate.ifNotExistsIncludingUnassigned(item_type,
@@ -757,7 +804,8 @@ def group_by(self, first_collector: 'BiConstraintCollector[A, B, Any, A_]',
...
def group_by(self, *args):
- """Collect items into groups using the group_key_function(s) and optionally aggregate the group's items into a
+ """
+ Collect items into groups using the group_key_function(s) and optionally aggregate the group's items into a
result.
The syntax of group_by is zero to four group_key functions, followed by zero to four collectors. At most
@@ -766,44 +814,43 @@ def group_by(self, *args):
If no group_key function is passed to group_by, all items in the stream are aggregated into a single result
by the passed constraint collectors.
- Examples:
-
- - # count the items in this stream; returns Uni[int]
+ Returns
+ -------
+ UniConstraintStream | BiConstraintStream | TriConstraintStream | QuadConstraintStream
+ The type of stream returned depends on the number of arguments passed:
- group_by(ConstraintCollectors.count_bi())
+ - 1 -> UniConstraintStream
- - # count the number of shifts each employee has; returns Bi[Employee]
+ - 2 -> BiConstraintStream
- group_by(lambda shift, _: shift.employee, ConstraintCollectors.count_bi())
+ - 3 -> TriConstraintStream
- - # count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
+ - 4 -> QuadConstraintStream
- group_by(lambda shift, _: shift.employee, lambda shift, _: shift.date, ConstraintCollectors.count_bi())
+ Examples
+ --------
- - # count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
+ Count the items in this stream; returns Uni[int]
- group_by(lambda shift, _: shift.employee, lambda shift, _: shift.date, ConstraintCollectors.count_bi())
+ >>> group_by(ConstraintCollectors.count())
- - # get the dates of the first and last shift of each employee; returns Tri[Employee, datetime.date,
- datetime.date]
+ Count the number of shifts each employee has; returns Bi[Employee]
- group_by(lambda shift, _: shift.employee,
- ConstraintCollectors.min(lambda shift, _: shift.date)
- ConstraintCollectors.max(lambda shift, _: shift.date))
+ >>> group_by(lambda shift: shift.employee, ConstraintCollectors.count())
- The type of stream returned depends on the number of arguments passed:
+ Count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
- - 1 -> UniConstraintStream
+ >>> group_by(lambda shift: shift.employee, lambda shift: shift.date, ConstraintCollectors.count())
- - 2 -> BiConstraintStream
+ Count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
- - 3 -> TriConstraintStream
+ >>> group_by(lambda shift: shift.employee, lambda shift: shift.date, ConstraintCollectors.count())
- - 4 -> QuadConstraintStream
+ Get the dates of the first and last shift of each employee; returns Tri[Employee, datetime.date, datetime.date]
- :param args:
-
- :return:
+ >>> group_by(lambda shift: shift.employee,
+ ... ConstraintCollectors.min(lambda shift: shift.date)
+ ... ConstraintCollectors.max(lambda shift: shift.date))
"""
return perform_group_by(self.delegate, self.package, args, self.a_type, self.b_type)
@@ -828,11 +875,8 @@ def map(self, mapping_function: Callable[[A, B], A_], mapping_function2: Callabl
...
def map(self, *mapping_functions):
- """Transforms the stream in such a way that tuples are remapped using the given function.
-
- :param mapping_functions:
-
- :return:
+ """
+ Transforms the stream in such a way that tuples are remapped using the given function.
"""
if len(mapping_functions) == 0:
raise ValueError(f'At least one mapping function is required for map.')
@@ -876,8 +920,6 @@ def expand(self, *mapping_functions):
which only increases stream cardinality and can not introduce duplicate tuples.
It enables you to add extra facts to each tuple in a constraint stream by applying a mapping function to it.
This is useful in situations where an expensive computations needs to be cached for use later in the stream.
- :param mapping_functions:
- :return:
"""
if len(mapping_functions) == 0:
raise ValueError(f'At least one mapping function is required for expand.')
@@ -899,11 +941,8 @@ def expand(self, *mapping_functions):
raise RuntimeError(f'Impossible state: missing case for {len(mapping_functions)}.')
def flatten_last(self, flattening_function: Callable[[B], B_]) -> 'BiConstraintStream[A,B_]':
- """Takes each tuple and applies a mapping on it, which turns the tuple into an Iterable.
-
- :param flattening_function:
-
- :return:
+ """
+ Takes each tuple and applies a mapping on it, which turns the tuple into an Iterable.
"""
translated_function = function_cast(flattening_function, self.b_type)
return BiConstraintStream(self.delegate.flattenLast(translated_function), self.package,
@@ -911,9 +950,8 @@ def flatten_last(self, flattening_function: Callable[[B], B_]) -> 'BiConstraintS
self.a_type, JClass('java.lang.Object'))
def distinct(self) -> 'BiConstraintStream[A,B]':
- """Transforms the stream in such a way that all the tuples going through it are distinct.
-
- :return:
+ """
+ Transforms the stream in such a way that all the tuples going through it are distinct.
"""
return BiConstraintStream(self.delegate.distinct(), self.package,
self.a_type, self.b_type)
@@ -943,8 +981,6 @@ def concat(self, other):
If the two constraint concatenating streams share tuples, which happens e.g.
when they come from the same source of data, the tuples will be repeated downstream.
If this is undesired, use the distinct building block.
- :param other:
- :return:
"""
if isinstance(other, UniConstraintStream):
return BiConstraintStream(self.delegate.concat(other.delegate), self.package,
@@ -966,6 +1002,24 @@ def concat(self, other):
def penalize(self, constraint_weight: ScoreType, match_weigher: Callable[[A, B], int] = None) -> \
'BiConstraintBuilder[A, B, ScoreType]':
+ """
+ Applies a negative Score impact, subtracting the constraint_weight multiplied by the match weight,
+ and returns a builder to apply optional constraint properties.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A, B], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ BiConstraintBuilder
+ a `BiConstraintBuilder`
+ """
if match_weigher is None:
return BiConstraintBuilder(self.delegate.penalize(constraint_weight), self.a_type, self.b_type)
else:
@@ -977,6 +1031,24 @@ def penalize(self, constraint_weight: ScoreType, match_weigher: Callable[[A, B],
def reward(self, constraint_weight: ScoreType, match_weigher: Callable[[A, B], int] = None) -> \
'BiConstraintBuilder[A, B, ScoreType]':
+ """
+ Applies a positive Score impact, adding the constraint_weight multiplied by the match weight,
+ and returns a builder to apply optional constraint properties.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A, B], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ BiConstraintBuilder
+ a `BiConstraintBuilder`
+ """
if match_weigher is None:
return BiConstraintBuilder(self.delegate.reward(constraint_weight), self.a_type, self.b_type)
else:
@@ -988,6 +1060,25 @@ def reward(self, constraint_weight: ScoreType, match_weigher: Callable[[A, B], i
def impact(self, constraint_weight: ScoreType, match_weigher: Callable[[A, B], int] = None) -> \
'BiConstraintBuilder[A, B, ScoreType]':
+ """
+ Positively or negatively impacts the `Score` by `constraint_weight` multiplied by match weight for each match
+ and returns a builder to apply optional constraint properties.
+ Use `penalize` or `reward` instead, unless this constraint can both have positive and negative weights.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A, B], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ BiConstraintBuilder
+ a `BiConstraintBuilder`
+ """
if match_weigher is None:
return BiConstraintBuilder(self.delegate.impact(constraint_weight), self.a_type, self.b_type)
else:
@@ -999,6 +1090,25 @@ def impact(self, constraint_weight: ScoreType, match_weigher: Callable[[A, B], i
def penalize_configurable(self, match_weigher: Callable[[A, B], int] = None) -> \
'BiConstraintBuilder[A, B, ScoreType]':
+ """
+ Negatively impacts the Score, subtracting the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+
+ Parameters
+ ----------
+ match_weigher : Callable[[A, B], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ BiConstraintBuilder
+ a `BiConstraintBuilder`
+ """
if match_weigher is None:
return BiConstraintBuilder(self.delegate.penalizeConfigurable(), self.a_type, self.b_type)
else:
@@ -1010,6 +1120,25 @@ def penalize_configurable(self, match_weigher: Callable[[A, B], int] = None) ->
def reward_configurable(self, match_weigher: Callable[[A, B], int] = None) -> \
'BiConstraintBuilder[A, B, ScoreType]':
+ """
+ Positively impacts the Score, adding the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+
+ Parameters
+ ----------
+ match_weigher : Callable[[A, B], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ BiConstraintBuilder
+ a `BiConstraintBuilder`
+ """
if match_weigher is None:
return BiConstraintBuilder(self.delegate.rewardConfigurable(), self.a_type, self.b_type)
else:
@@ -1021,6 +1150,25 @@ def reward_configurable(self, match_weigher: Callable[[A, B], int] = None) -> \
def impact_configurable(self, match_weigher: Callable[[A, B], int] = None) -> \
'BiConstraintBuilder[A, B, ScoreType]':
+ """
+ Positively or negatively impacts the Score, adding the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+
+ Parameters
+ ----------
+ match_weigher : Callable[[A, B], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ BiConstraintBuilder
+ a `BiConstraintBuilder`
+ """
if match_weigher is None:
return BiConstraintBuilder(self.delegate.impactConfigurable(), self.a_type, self.b_type)
else:
@@ -1032,6 +1180,9 @@ def impact_configurable(self, match_weigher: Callable[[A, B], int] = None) -> \
class TriConstraintStream(Generic[A, B, C]):
+ """
+ A ConstraintStream that matches three facts.
+ """
delegate: '_JavaTriConstraintStream[A,B,C]'
package: str
a_type: Type[A]
@@ -1053,14 +1204,14 @@ def __init__(self, delegate: '_JavaTriConstraintStream[A,B,C]', package: str,
self.c_type = c_type
def get_constraint_factory(self):
+ """
+ The ConstraintFactory that build this.
+ """
return ConstraintFactory(self.delegate.getConstraintFactory())
def filter(self, predicate: Callable[[A, B, C], bool]) -> 'TriConstraintStream[A,B,C]':
- """Exhaustively test each fact against the predicate and match if the predicate returns True.
-
- :param predicate:
-
- :return:
+ """
+ Exhaustively test each fact against the predicate and match if the predicate returns ``True``.
"""
translated_predicate = predicate_cast(predicate, self.a_type, self.b_type, self.c_type)
return TriConstraintStream(self.delegate.filter(translated_predicate), self.package,
@@ -1069,13 +1220,8 @@ def filter(self, predicate: Callable[[A, B, C], bool]) -> 'TriConstraintStream[A
def join(self, unistream_or_type: Union[UniConstraintStream[D_], Type[D_]],
*joiners: 'QuadJoiner[A, B, C, D_]') -> 'QuadConstraintStream[A,B,C,D_]':
- """Create a new QuadConstraintStream for every combination of A, B and C that satisfy all specified joiners.
-
- :param unistream_or_type:
-
- :param joiners:
-
- :return:
+ """
+ Create a new `QuadConstraintStream` for every combination of A, B and C that satisfy all specified joiners.
"""
d_type = None
if isinstance(unistream_or_type, UniConstraintStream):
@@ -1092,13 +1238,8 @@ def join(self, unistream_or_type: Union[UniConstraintStream[D_], Type[D_]],
def if_exists(self, item_type: Type[D_], *joiners: 'QuadJoiner[A, B, C, D_]') -> \
'TriConstraintStream[A,B,C]':
- """Create a new TriConstraintStream for every A, B, C where D exists that satisfy all specified joiners.
-
- :param item_type:
-
- :param joiners:
-
- :return:
+ """
+ Create a new `TriConstraintStream` for every A, B, C where D exists that satisfy all specified joiners.
"""
item_type = get_class(item_type)
return TriConstraintStream(self.delegate.ifExists(item_type, extract_joiners(joiners, self.a_type,
@@ -1108,13 +1249,8 @@ def if_exists(self, item_type: Type[D_], *joiners: 'QuadJoiner[A, B, C, D_]') ->
def if_exists_including_unassigned(self, item_type: Type[D_], *joiners: 'QuadJoiner[A, B, C, D_]') -> \
'TriConstraintStream[A,B,C]':
- """Create a new TriConstraintStream for every A, B where D exists that satisfy all specified joiners.
-
- :param item_type:
-
- :param joiners:
-
- :return:
+ """
+ Create a new `TriConstraintStream` for every A, B where D exists that satisfy all specified joiners.
"""
item_type = get_class(item_type)
return TriConstraintStream(self.delegate.ifExistsIncludingUnassigned(item_type, extract_joiners(joiners,
@@ -1126,14 +1262,9 @@ def if_exists_including_unassigned(self, item_type: Type[D_], *joiners: 'QuadJoi
def if_not_exists(self, item_type: Type[D_], *joiners: 'QuadJoiner[A, B, C, D_]') -> \
'TriConstraintStream[A,B,C]':
- """Create a new TriConstraintStream for every A, B, C where there does not exist a D where all specified joiners
+ """
+ Create a new `TriConstraintStream` for every A, B, C where there does not exist a D where all specified joiners
are satisfied.
-
- :param item_type:
-
- :param joiners:
-
- :return:
"""
item_type = get_class(item_type)
return TriConstraintStream(self.delegate.ifNotExists(item_type, extract_joiners(joiners,
@@ -1145,14 +1276,9 @@ def if_not_exists(self, item_type: Type[D_], *joiners: 'QuadJoiner[A, B, C, D_]'
def if_not_exists_including_unassigned(self, item_type: Type[D_], *joiners: 'QuadJoiner[A, B, C, D_]') -> \
'TriConstraintStream[A,B,C]':
- """Create a new TriConstraintStream for every A, B, C where there does not exist a D where all specified joiners
+ """
+ Create a new `TriConstraintStream` for every A, B, C where there does not exist a D where all specified joiners
are satisfied.
-
- :param item_type:
-
- :param joiners:
-
- :return:
"""
item_type = get_class(item_type)
return TriConstraintStream(self.delegate.ifNotExistsIncludingUnassigned(item_type,
@@ -1243,7 +1369,8 @@ def group_by(self, first_collector: 'TriConstraintCollector[A, B, C, Any, A_]',
...
def group_by(self, *args):
- """Collect items into groups using the group_key_function(s) and optionally aggregate the group's items into a
+ """
+ Collect items into groups using the group_key_function(s) and optionally aggregate the group's items into a
result.
The syntax of group_by is zero to four group_key functions, followed by zero to four collectors. At most
@@ -1252,46 +1379,43 @@ def group_by(self, *args):
If no group_key function is passed to group_by, all items in the stream are aggregated into a single result
by the passed constraint collectors.
- Examples:
-
- - # count the items in this stream; returns Uni[int]
+ Returns
+ -------
+ UniConstraintStream | BiConstraintStream | TriConstraintStream | QuadConstraintStream
+ The type of stream returned depends on the number of arguments passed:
- group_by(ConstraintCollectors.count_tri())
+ - 1 -> UniConstraintStream
- - # count the number of shifts each employee has; returns Bi[Employee]
+ - 2 -> BiConstraintStream
- group_by(lambda shift, _, _: shift.employee, ConstraintCollectors.count_tri())
+ - 3 -> TriConstraintStream
- - # count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
+ - 4 -> QuadConstraintStream
- group_by(lambda shift, _, _: shift.employee, lambda shift, _, _: shift.date,
- ConstraintCollectors.count_tri())
+ Examples
+ --------
- - # count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
+ Count the items in this stream; returns Uni[int]
- group_by(lambda shift, _, _: shift.employee, lambda shift, _, _: shift.date,
- ConstraintCollectors.count_tri())
+ >>> group_by(ConstraintCollectors.count())
- - # get the dates of the first and last shift of each employee; returns Tri[Employee, datetime.date,
- datetime.date]
+ Count the number of shifts each employee has; returns Bi[Employee]
- group_by(lambda shift, _, _: shift.employee,
- ConstraintCollectors.min(lambda shift, _, _: shift.date)
- ConstraintCollectors.max(lambda shift, _, _: shift.date))
+ >>> group_by(lambda shift: shift.employee, ConstraintCollectors.count())
- The type of stream returned depends on the number of arguments passed:
+ Count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
- - 1 -> UniConstraintStream
+ >>> group_by(lambda shift: shift.employee, lambda shift: shift.date, ConstraintCollectors.count())
- - 2 -> BiConstraintStream
+ Count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
- - 3 -> TriConstraintStream
+ >>> group_by(lambda shift: shift.employee, lambda shift: shift.date, ConstraintCollectors.count())
- - 4 -> QuadConstraintStream
+ Get the dates of the first and last shift of each employee; returns Tri[Employee, datetime.date, datetime.date]
- :param args:
-
- :return:
+ >>> group_by(lambda shift: shift.employee,
+ ... ConstraintCollectors.min(lambda shift: shift.date)
+ ... ConstraintCollectors.max(lambda shift: shift.date))
"""
return perform_group_by(self.delegate, self.package, args, self.a_type, self.b_type, self.c_type)
@@ -1316,11 +1440,8 @@ def map(self, mapping_function: Callable[[A, B, C], A_], mapping_function2: Call
...
def map(self, *mapping_functions):
- """Transforms the stream in such a way that tuples are remapped using the given function.
-
- :param mapping_functions:
-
- :return:
+ """
+ Transforms the stream in such a way that tuples are remapped using the given function.
"""
if len(mapping_functions) == 0:
raise ValueError(f'At least one mapping function is required for map.')
@@ -1355,8 +1476,6 @@ def expand(self, mapping_function: Callable[[A, B, C], D_]) -> 'QuadConstraintSt
which only increases stream cardinality and can not introduce duplicate tuples.
It enables you to add extra facts to each tuple in a constraint stream by applying a mapping function to it.
This is useful in situations where an expensive computations needs to be cached for use later in the stream.
- :param mapping_function:
- :return:
"""
translated_function = function_cast(mapping_function, self.a_type, self.b_type, self.c_type)
return QuadConstraintStream(self.delegate.expand(translated_function), self.package,
@@ -1364,11 +1483,8 @@ def expand(self, mapping_function: Callable[[A, B, C], D_]) -> 'QuadConstraintSt
self.a_type, self.b_type, self.c_type, JClass('java.lang.Object'))
def flatten_last(self, flattening_function: Callable[[C], C_]) -> 'TriConstraintStream[A,B,C_]':
- """Takes each tuple and applies a mapping on it, which turns the tuple into an Iterable.
-
- :param flattening_function:
-
- :return:
+ """
+ Takes each tuple and applies a mapping on it, which turns the tuple into an Iterable.
"""
translated_function = function_cast(flattening_function, self.c_type)
return TriConstraintStream(self.delegate.flattenLast(translated_function), self.package,
@@ -1376,9 +1492,8 @@ def flatten_last(self, flattening_function: Callable[[C], C_]) -> 'TriConstraint
self.a_type, self.b_type, JClass('java.lang.Object'))
def distinct(self) -> 'TriConstraintStream[A, B, C]':
- """Transforms the stream in such a way that all the tuples going through it are distinct.
-
- :return:
+ """
+ Transforms the stream in such a way that all the tuples going through it are distinct.
"""
return TriConstraintStream(self.delegate.distinct(), self.package,
self.a_type,
@@ -1409,8 +1524,6 @@ def concat(self, other):
If the two constraint concatenating streams share tuples, which happens e.g.
when they come from the same source of data, the tuples will be repeated downstream.
If this is undesired, use the distinct building block.
- :param other:
- :return:
"""
if isinstance(other, UniConstraintStream):
return TriConstraintStream(self.delegate.concat(other.delegate), self.package,
@@ -1433,6 +1546,24 @@ def concat(self, other):
def penalize(self, constraint_weight: ScoreType,
match_weigher: Callable[[A, B, C], int] = None) -> 'TriConstraintBuilder[A, B, C, ScoreType]':
+ """
+ Applies a negative Score impact, subtracting the constraint_weight multiplied by the match weight,
+ and returns a builder to apply optional constraint properties.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A, B, C], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ TriConstraintBuilder
+ a `TriConstraintBuilder`
+ """
if match_weigher is None:
return TriConstraintBuilder(self.delegate.penalize(constraint_weight),
self.a_type, self.b_type, self.c_type)
@@ -1446,6 +1577,24 @@ def penalize(self, constraint_weight: ScoreType,
def reward(self, constraint_weight: ScoreType, match_weigher: Callable[[A, B, C], int] = None) -> \
'TriConstraintBuilder[A, B, C, ScoreType]':
+ """
+ Applies a positive Score impact, adding the constraint_weight multiplied by the match weight,
+ and returns a builder to apply optional constraint properties.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A, B, C], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ TriConstraintBuilder
+ a `TriConstraintBuilder`
+ """
if match_weigher is None:
return TriConstraintBuilder(self.delegate.reward(constraint_weight), self.a_type, self.b_type, self.c_type)
else:
@@ -1458,6 +1607,25 @@ def reward(self, constraint_weight: ScoreType, match_weigher: Callable[[A, B, C]
def impact(self, constraint_weight: ScoreType,
match_weigher: Callable[[A, B, C], int] = None) -> 'TriConstraintBuilder[A, B, C, ScoreType]':
+ """
+ Positively or negatively impacts the `Score` by `constraint_weight` multiplied by match weight for each match
+ and returns a builder to apply optional constraint properties.
+ Use `penalize` or `reward` instead, unless this constraint can both have positive and negative weights.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A, B, C], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ TriConstraintBuilder
+ a `TriConstraintBuilder`
+ """
if match_weigher is None:
return TriConstraintBuilder(self.delegate.impact(constraint_weight),
self.a_type, self.b_type, self.c_type)
@@ -1471,6 +1639,25 @@ def impact(self, constraint_weight: ScoreType,
def penalize_configurable(self, match_weigher: Callable[[A, B, C], int] = None) \
-> 'TriConstraintBuilder[A, B, C, ScoreType]':
+ """
+ Negatively impacts the Score, subtracting the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+
+ Parameters
+ ----------
+ match_weigher : Callable[[A, B, C], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ TriConstraintBuilder
+ a `TriConstraintBuilder`
+ """
if match_weigher is None:
return TriConstraintBuilder(self.delegate.penalizeConfigurable(),
self.a_type, self.b_type, self.c_type)
@@ -1484,6 +1671,25 @@ def penalize_configurable(self, match_weigher: Callable[[A, B, C], int] = None)
def reward_configurable(self, match_weigher: Callable[[A, B, C], int] = None) -> \
'TriConstraintBuilder[A, B, C, ScoreType]':
+ """
+ Positively impacts the Score, adding the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+
+ Parameters
+ ----------
+ match_weigher : Callable[[A, B, C], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ TriConstraintBuilder
+ a `TriConstraintBuilder`
+ """
if match_weigher is None:
return TriConstraintBuilder(self.delegate.rewardConfigurable(),
self.a_type, self.b_type, self.c_type)
@@ -1497,6 +1703,25 @@ def reward_configurable(self, match_weigher: Callable[[A, B, C], int] = None) ->
def impact_configurable(self, match_weigher: Callable[[A, B, C], int] = None) \
-> 'TriConstraintBuilder[A, B, C, ScoreType]':
+ """
+ Positively or negatively impacts the Score, adding the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+
+ Parameters
+ ----------
+ match_weigher : Callable[[A, B, C], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ TriConstraintBuilder
+ a `TriConstraintBuilder`
+ """
if match_weigher is None:
return TriConstraintBuilder(self.delegate.impactConfigurable(),
self.a_type, self.b_type, self.c_type)
@@ -1510,6 +1735,9 @@ def impact_configurable(self, match_weigher: Callable[[A, B, C], int] = None) \
class QuadConstraintStream(Generic[A, B, C, D]):
+ """
+ A ConstraintStream that matches four facts.
+ """
delegate: '_JavaQuadConstraintStream[A,B,C,D]'
package: str
a_type: Type[A]
@@ -1533,14 +1761,14 @@ def __init__(self, delegate: '_JavaQuadConstraintStream[A,B,C,D]', package: str,
self.d_type = d_type
def get_constraint_factory(self):
+ """
+ The ConstraintFactory that build this.
+ """
return ConstraintFactory(self.delegate.getConstraintFactory())
def filter(self, predicate: Callable[[A, B, C, D], bool]) -> 'QuadConstraintStream[A,B,C,D]':
- """Exhaustively test each fact against the predicate and match if the predicate returns True.
-
- :param predicate:
-
- :return:
+ """
+ Exhaustively test each fact against the predicate and match if the predicate returns ``True``.
"""
translated_predicate = predicate_cast(predicate, self.a_type, self.b_type, self.c_type, self.d_type)
return QuadConstraintStream(self.delegate.filter(translated_predicate), self.package,
@@ -1549,13 +1777,8 @@ def filter(self, predicate: Callable[[A, B, C, D], bool]) -> 'QuadConstraintStre
def if_exists(self, item_type: Type[E_], *joiners: 'PentaJoiner[A, B, C, D, E_]') -> \
'QuadConstraintStream[A,B,C,D]':
- """Create a new QuadConstraintStream for every A, B, C, D where E exists that satisfy all specified joiners.
-
- :param item_type:
-
- :param joiners:
-
- :return:
+ """
+ Create a new `QuadConstraintStream` for every A, B, C, D where E exists that satisfy all specified joiners.
"""
item_type = get_class(item_type)
return QuadConstraintStream(self.delegate.ifExists(item_type, extract_joiners(joiners,
@@ -1569,13 +1792,8 @@ def if_exists(self, item_type: Type[E_], *joiners: 'PentaJoiner[A, B, C, D, E_]'
def if_exists_including_unassigned(self, item_type: Type[E_], *joiners: 'PentaJoiner[A, B, C, D, E_]') -> \
'QuadConstraintStream[A,B,C,D]':
- """Create a new QuadConstraintStream for every A, B, C, D where E exists that satisfy all specified joiners.
-
- :param item_type:
-
- :param joiners:
-
- :return:
+ """
+ Create a new `QuadConstraintStream` for every A, B, C, D where E exists that satisfy all specified joiners.
"""
item_type = get_class(item_type)
return QuadConstraintStream(self.delegate.ifExistsIncludingUnassigned(item_type,
@@ -1590,14 +1808,9 @@ def if_exists_including_unassigned(self, item_type: Type[E_], *joiners: 'PentaJo
def if_not_exists(self, item_type: Type[E_], *joiners: 'PentaJoiner[A, B, C, D, E_]') -> \
'QuadConstraintStream[A,B,C,D]':
- """Create a new QuadConstraintStream for every A, B, C, D where there does not exist an E where all specified
+ """
+ Create a new `QuadConstraintStream` for every A, B, C, D where there does not exist an E where all specified
joiners are satisfied.
-
- :param item_type:
-
- :param joiners:
-
- :return:
"""
item_type = get_class(item_type)
return QuadConstraintStream(self.delegate.ifNotExists(item_type, extract_joiners(joiners,
@@ -1611,14 +1824,9 @@ def if_not_exists(self, item_type: Type[E_], *joiners: 'PentaJoiner[A, B, C, D,
def if_not_exists_including_unassigned(self, item_type: Type[E_], *joiners: 'PentaJoiner[A, B, C, D, E_]') -> \
'QuadConstraintStream[A,B,C,D]':
- """Create a new QuadConstraintStream for every A, B, C, D where there does not exist an E where all specified
+ """
+ Create a new `QuadConstraintStream` for every A, B, C, D where there does not exist an E where all specified
joiners are satisfied.
-
- :param item_type:
-
- :param joiners:
-
- :return:
"""
item_type = get_class(item_type)
return QuadConstraintStream(self.delegate.ifNotExistsIncludingUnassigned(item_type,
@@ -1712,7 +1920,8 @@ def group_by(self, first_collector: 'QuadConstraintCollector[A, B, C, D, Any, A_
...
def group_by(self, *args):
- """Collect items into groups using the group_key_function(s) and optionally aggregate the group's items into a
+ """
+ Collect items into groups using the group_key_function(s) and optionally aggregate the group's items into a
result.
The syntax of group_by is zero to four group_key functions, followed by zero to four collectors. At most
@@ -1721,46 +1930,43 @@ def group_by(self, *args):
If no group_key function is passed to group_by, all items in the stream are aggregated into a single result
by the passed constraint collectors.
- Examples:
-
- - # count the items in this stream; returns Uni[int]
+ Returns
+ -------
+ UniConstraintStream | BiConstraintStream | TriConstraintStream | QuadConstraintStream
+ The type of stream returned depends on the number of arguments passed:
- group_by(ConstraintCollectors.count_quad())
+ - 1 -> UniConstraintStream
- - # count the number of shifts each employee has; returns Bi[Employee]
+ - 2 -> BiConstraintStream
- group_by(lambda shift, _, _, _: shift.employee, ConstraintCollectors.count_quad())
+ - 3 -> TriConstraintStream
- - # count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
+ - 4 -> QuadConstraintStream
- group_by(lambda shift, _, _, _: shift.employee, lambda shift, _, _, _: shift.date,
- ConstraintCollectors.count_quad())
+ Examples
+ --------
- - # count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
+ Count the items in this stream; returns Uni[int]
- group_by(lambda shift, _, _, _: shift.employee, lambda shift, _, _, _: shift.date,
- ConstraintCollectors.count_quad())
+ >>> group_by(ConstraintCollectors.count())
- - # get the dates of the first and last shift of each employee; returns Tri[Employee, datetime.date,
- datetime.date]
+ Count the number of shifts each employee has; returns Bi[Employee]
- group_by(lambda shift, _, _, _: shift.employee,
- ConstraintCollectors.min(lambda shift, _, _, _: shift.date)
- ConstraintCollectors.max(lambda shift, _, _, _: shift.date))
+ >>> group_by(lambda shift: shift.employee, ConstraintCollectors.count())
- The type of stream returned depends on the number of arguments passed:
+ Count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
- - 1 -> UniConstraintStream
+ >>> group_by(lambda shift: shift.employee, lambda shift: shift.date, ConstraintCollectors.count())
- - 2 -> BiConstraintStream
+ Count the number of shifts each employee has on a date; returns Tri[Employee, datetime.date, int]
- - 3 -> TriConstraintStream
+ >>> group_by(lambda shift: shift.employee, lambda shift: shift.date, ConstraintCollectors.count())
- - 4 -> QuadConstraintStream
+ Get the dates of the first and last shift of each employee; returns Tri[Employee, datetime.date, datetime.date]
- :param args:
-
- :return:
+ >>> group_by(lambda shift: shift.employee,
+ ... ConstraintCollectors.min(lambda shift: shift.date)
+ ... ConstraintCollectors.max(lambda shift: shift.date))
"""
return perform_group_by(self.delegate, self.package, args, self.a_type, self.b_type, self.c_type, self.d_type)
@@ -1785,11 +1991,8 @@ def map(self, mapping_function: Callable[[A, B, C, D], A_], mapping_function2: C
...
def map(self, *mapping_functions):
- """Transforms the stream in such a way that tuples are remapped using the given function.
-
- :param mapping_functions:
-
- :return:
+ """
+ Transforms the stream in such a way that tuples are remapped using the given function.
"""
if len(mapping_functions) == 0:
raise ValueError(f'At least one mapping function is required for map.')
@@ -1819,11 +2022,8 @@ def map(self, *mapping_functions):
raise RuntimeError(f'Impossible state: missing case for {len(mapping_functions)}.')
def flatten_last(self, flattening_function) -> 'QuadConstraintStream[A,B,C,D]':
- """Takes each tuple and applies a mapping on it, which turns the tuple into an Iterable.
-
- :param flattening_function:
-
- :return:
+ """
+ Takes each tuple and applies a mapping on it, which turns the tuple into an Iterable.
"""
translated_function = function_cast(flattening_function, self.d_type)
return QuadConstraintStream(self.delegate.flattenLast(translated_function), self.package,
@@ -1831,9 +2031,8 @@ def flatten_last(self, flattening_function) -> 'QuadConstraintStream[A,B,C,D]':
self.a_type, self.b_type, self.c_type, JClass('java.lang.Object'))
def distinct(self) -> 'QuadConstraintStream[A,B,C,D]':
- """Transforms the stream in such a way that all the tuples going through it are distinct.
-
- :return:
+ """
+ Transforms the stream in such a way that all the tuples going through it are distinct.
"""
return QuadConstraintStream(self.delegate.distinct(), self.package,
self.a_type,
@@ -1864,8 +2063,6 @@ def concat(self, other):
If the two constraint concatenating streams share tuples, which happens e.g.
when they come from the same source of data, the tuples will be repeated downstream.
If this is undesired, use the distinct building block.
- :param other:
- :return:
"""
if isinstance(other, UniConstraintStream):
return QuadConstraintStream(self.delegate.concat(other.delegate), self.package,
@@ -1888,6 +2085,24 @@ def concat(self, other):
def penalize(self, constraint_weight: ScoreType,
match_weigher: Callable[[A, B, C, D], int] = None) -> 'QuadConstraintBuilder[A, B, C, D, ScoreType]':
+ """
+ Applies a negative Score impact, subtracting the constraint_weight multiplied by the match weight,
+ and returns a builder to apply optional constraint properties.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A, B, C, D], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ QuadConstraintBuilder
+ a `QuadConstraintBuilder`
+ """
if match_weigher is None:
return QuadConstraintBuilder(self.delegate.penalize(constraint_weight),
self.a_type, self.b_type, self.c_type, self.d_type)
@@ -1902,6 +2117,24 @@ def penalize(self, constraint_weight: ScoreType,
def reward(self, constraint_weight: ScoreType,
match_weigher: Callable[[A, B, C, D], int] = None) -> 'QuadConstraintBuilder[A, B, C, D, ScoreType]':
+ """
+ Applies a positive Score impact, adding the constraint_weight multiplied by the match weight,
+ and returns a builder to apply optional constraint properties.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A, B, C, D], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ QuadConstraintBuilder
+ a `QuadConstraintBuilder`
+ """
if match_weigher is None:
return QuadConstraintBuilder(self.delegate.reward(constraint_weight),
self.a_type, self.b_type, self.c_type, self.d_type)
@@ -1916,6 +2149,25 @@ def reward(self, constraint_weight: ScoreType,
def impact(self, constraint_weight: ScoreType,
match_weigher: Callable[[A, B, C, D], int] = None) -> 'QuadConstraintBuilder[A, B, C, D, ScoreType]':
+ """
+ Positively or negatively impacts the `Score` by `constraint_weight` multiplied by match weight for each match
+ and returns a builder to apply optional constraint properties.
+ Use `penalize` or `reward` instead, unless this constraint can both have positive and negative weights.
+
+ Parameters
+ ----------
+ constraint_weight : Score
+ the weight of the constraint.
+
+ match_weigher : Callable[[A, B, C, D], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ QuadConstraintBuilder
+ a `QuadConstraintBuilder`
+ """
if match_weigher is None:
return QuadConstraintBuilder(self.delegate.impact(constraint_weight),
self.a_type, self.b_type, self.c_type, self.d_type)
@@ -1930,6 +2182,25 @@ def impact(self, constraint_weight: ScoreType,
def penalize_configurable(self, match_weigher: Callable[[A, B, C, D], int] = None) \
-> 'QuadConstraintBuilder[A, B, C, D, ScoreType]':
+ """
+ Negatively impacts the Score, subtracting the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+
+ Parameters
+ ----------
+ match_weigher : Callable[[A, B, C, D], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ QuadConstraintBuilder
+ a `QuadConstraintBuilder`
+ """
if match_weigher is None:
return QuadConstraintBuilder(self.delegate.penalizeConfigurable(),
self.a_type, self.b_type, self.c_type, self.d_type)
@@ -1944,6 +2215,24 @@ def penalize_configurable(self, match_weigher: Callable[[A, B, C, D], int] = Non
def reward_configurable(self, match_weigher: Callable[[A, B, C, D], int] = None) \
-> 'QuadConstraintBuilder[A, B, C, D, ScoreType]':
+ """
+ Positively impacts the Score, adding the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+ Parameters
+ ----------
+ match_weigher : Callable[[A, B, C, D], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ QuadConstraintBuilder
+ a `QuadConstraintBuilder`
+ """
if match_weigher is None:
return QuadConstraintBuilder(self.delegate.rewardConfigurable(),
self.a_type, self.b_type, self.c_type, self.d_type)
@@ -1958,6 +2247,25 @@ def reward_configurable(self, match_weigher: Callable[[A, B, C, D], int] = None)
def impact_configurable(self, match_weigher: Callable[[A, B, C, D], int] = None) \
-> 'QuadConstraintBuilder[A, B, C, D, ScoreType]':
+ """
+ Positively or negatively impacts the Score, adding the ConstraintWeight for each match,
+ and returns a builder to apply optional constraint properties.
+ The constraint weight comes from a `ConstraintWeight` annotated member on the `constraint_configuration`,
+ so end users can change the constraint weights dynamically.
+ This constraint may be deactivated if the `ConstraintWeight` is zero.
+ If there is no `constraint_configuration`, use `penalize` instead.
+
+ Parameters
+ ----------
+ match_weigher : Callable[[A, B, C, D], int]
+ a function that computes the weight of a match.
+ If absent, each match has weight ``1``.
+
+ Returns
+ -------
+ QuadConstraintBuilder
+ a `QuadConstraintBuilder`
+ """
if match_weigher is None:
return QuadConstraintBuilder(self.delegate.impactConfigurable(),
self.a_type, self.b_type, self.c_type, self.d_type)
diff --git a/timefold-solver-python-core/src/main/python/score/_group_by.py b/timefold-solver-python-core/src/main/python/score/_group_by.py
index 5f8e1d8..68c482c 100644
--- a/timefold-solver-python-core/src/main/python/score/_group_by.py
+++ b/timefold-solver-python-core/src/main/python/score/_group_by.py
@@ -125,6 +125,10 @@ def perform_group_by(constraint_stream, package, group_by_args, *type_arguments)
class ConstraintCollectors:
+ """
+ Creates an UniConstraintCollector, BiConstraintCollector, ...
+ instances for use in `UniConstraintStream.group_by`, ...
+ """
# Method parameter type variables
A = TypeVar('A')
B = TypeVar('B')
@@ -168,11 +172,8 @@ def average(group_value_mapping: Callable[[A, B, C, D], int]) -> 'QuadConstraint
@staticmethod
def average(group_value_mapping):
- """Returns a collector that calculates an average of an int property of the elements that are being grouped.
-
- :param group_value_mapping:
-
- :return:
+ """
+ Returns a collector that calculates an average of an int property of the elements that are being grouped.
"""
return GroupIntMappingSingleArgConstraintCollector(ConstraintCollectors._delegate().average,
group_value_mapping)
@@ -275,9 +276,8 @@ def compose(sub_collector_1: 'QuadConstraintCollector[A, B, C, D, Any, A_]',
@staticmethod
def compose(*args):
- """Returns a constraint collector the result of which is a composition of other constraint collectors.
-
- :return:
+ """
+ Returns a constraint collector the result of which is a composition of other constraint collectors.
"""
if len(args) < 3: # Need at least two collectors + 1 compose function
raise ValueError
@@ -313,14 +313,9 @@ def conditionally(predicate: Callable[[A, B, C, D], bool],
@staticmethod
def conditionally(predicate, delegate):
- """Returns a collector that delegates to the underlying collector if and only if the input tuple meets the given
+ """
+ Returns a collector that delegates to the underlying collector if and only if the input tuple meets the given
condition.
-
- :param predicate:
-
- :param delegate:
-
- :return:
"""
return ConditionalConstraintCollector(ConstraintCollectors._delegate().conditionally,
predicate,
@@ -355,12 +350,8 @@ def collect_and_then(delegate: 'QuadConstraintCollector[A, B, C, D, Any, A_]',
@staticmethod
def collect_and_then(delegate, mapping_function):
- """Returns a collector that delegates to the underlying collector and maps its result to another value.
-
- :param delegate:
- :param mapping_function:
-
- :return:
+ """
+ Returns a collector that delegates to the underlying collector and maps its result to another value.
"""
return CollectAndThenCollector(ConstraintCollectors._delegate().collectAndThen,
delegate,
@@ -368,33 +359,29 @@ def collect_and_then(delegate, mapping_function):
@staticmethod
def count() -> 'UniConstraintCollector[A, Any, int]':
- """Returns a collector that counts the number of elements that are being grouped.
-
- :return:
+ """
+ Returns a collector that counts the number of elements that are being grouped.
"""
return NoArgsConstraintCollector(ConstraintCollectors._delegate().count) # noqa
@staticmethod
def count_bi() -> 'BiConstraintCollector[A, B, Any, int]':
- """Returns a collector that counts the number of elements that are being grouped.
-
- :return:
+ """
+ Returns a collector that counts the number of elements that are being grouped.
"""
return NoArgsConstraintCollector(ConstraintCollectors._delegate().countBi) # noqa
@staticmethod
def count_tri() -> 'TriConstraintCollector[A, B, C, Any, int]':
- """Returns a collector that counts the number of elements that are being grouped.
-
- :return:
+ """
+ Returns a collector that counts the number of elements that are being grouped.
"""
return NoArgsConstraintCollector(ConstraintCollectors._delegate().countTri) # noqa
@staticmethod
def count_quad() -> 'QuadConstraintCollector[A, B, C, D, Any, int]':
- """Returns a collector that counts the number of elements that are being grouped.
-
- :return:
+ """
+ Returns a collector that counts the number of elements that are being grouped.
"""
return NoArgsConstraintCollector(ConstraintCollectors._delegate().countQuad) # noqa
@@ -426,9 +413,8 @@ def count_distinct(group_value_mapping: Callable[[A, B, C, D], int]) -> \
@staticmethod
def count_distinct(function=None):
- """Returns a collector that counts the number of unique elements that are being grouped.
-
- :return:
+ """
+ Returns a collector that counts the number of unique elements that are being grouped.
"""
if function is None:
return NoArgsConstraintCollector(ConstraintCollectors._delegate().countDistinct)
@@ -491,9 +477,8 @@ def max(group_value_mapping: Callable[[A, B, C, D], A_], comparator: Callable[[A
@staticmethod
def max(function=None, comparator=None):
- """Returns a collector that finds a maximum value in a group of Comparable elements.
-
- :return:
+ """
+ Returns a collector that finds a maximum value in a group of comparable elements.
"""
if function is None and comparator is None:
return NoArgsConstraintCollector(ConstraintCollectors._delegate().max)
@@ -560,9 +545,8 @@ def min(group_value_mapping: Callable[[A, B, C, D], A_], comparator: Callable[[A
@staticmethod
def min(function=None, comparator=None):
- """Returns a collector that finds a minimum value in a group of Comparable elements.
-
- :return:
+ """
+ Returns a collector that finds a minimum value in a group of comparable elements.
"""
if function is None and comparator is None:
return NoArgsConstraintCollector(ConstraintCollectors._delegate().min)
@@ -622,14 +606,8 @@ def sum(function: Callable[[A, B, C, D], A_], zero: A_, adder: Callable[[A_, A_]
@staticmethod
def sum(function, zero=None, adder=None, subtractor=None):
- """Returns a collector that sums an int property of the elements that are being grouped.
-
- :param function:
- :param zero:
- :param adder:
- :param subtractor:
-
- :return:
+ """
+ Returns a collector that sums an int property of the elements that are being grouped.
"""
if zero is None and adder is None and subtractor is None:
return GroupIntMappingSingleArgConstraintCollector(ConstraintCollectors._delegate().sum, function)
@@ -664,6 +642,15 @@ def to_consecutive_sequences(result_map: Callable[[A, B, C, D], A_], index_map:
@staticmethod
def to_consecutive_sequences(result_or_index_map, index_map=None):
+ """
+ Creates a constraint collector that returns SequenceChain about the first fact.
+
+ For instance, [Shift slot=1] [Shift slot=2] [Shift slot=4] [Shift slot=6] returns the following information:
+
+ - Consecutive Lengths: 2, 1, 1
+ - Break Lengths: 1, 2
+ - Consecutive Items: [[Shift slot=1] [Shift slot=2]], [[Shift slot=4]], [[Shift slot=6]]
+ """
if index_map is None:
return GroupIntMappingSingleArgConstraintCollector(ConstraintCollectors._delegate().toConsecutiveSequences,
result_or_index_map)
@@ -672,48 +659,6 @@ def to_consecutive_sequences(result_or_index_map, index_map=None):
ConstraintCollectors._delegate().toConsecutiveSequences,
result_or_index_map, index_map)
- toConsecutiveSequences = to_consecutive_sequences
-
- @overload # noqa
- @staticmethod
- def to_collection(collection_creator: Callable[[int], B_]) -> 'UniConstraintCollector[A, Any, B_]':
- ...
-
- @overload # noqa
- @staticmethod
- def to_collection(group_value_mapping: Callable[[A], A_], collection_creator: Callable[[int], B_]) -> \
- 'UniConstraintCollector[A, Any, B_]':
- ...
-
- @overload # noqa
- @staticmethod
- def to_collection(group_value_mapping: Callable[[A, B], A_], collection_creator: Callable[[int], B_]) -> \
- 'BiConstraintCollector[A, B, Any, B_]':
- ...
-
- @overload # noqa
- @staticmethod
- def to_collection(group_value_mapping: Callable[[A, B, C], A_], collection_creator: Callable[[int], B_]) -> \
- 'TriConstraintCollector[A, B, C, Any, B_]':
- ...
-
- @overload # noqa
- @staticmethod
- def to_collection(group_value_mapping: Callable[[A, B, C, D], A_], collection_creator: Callable[[int], B_]) -> \
- 'QuadConstraintCollector[A, B, C, D, Any, B_]':
- ...
-
- @staticmethod
- def to_collection(group_value_mapping_or_collection_creator, collection_creator=None):
- """Deprecated; use to_list, to_set or to_sorted_set instead
-
- :return:
- """
- if collection_creator is None:
- raise NotImplementedError # TODO
- else:
- raise NotImplementedError # TODO
-
@overload # noqa
@staticmethod
def to_list() -> 'UniConstraintCollector[A, Any, List[A]]':
@@ -742,9 +687,8 @@ def to_list(group_value_mapping: Callable[[A, B, C, D], A_]) -> \
@staticmethod
def to_list(group_value_mapping=None):
- """Creates constraint collector that returns List of the given element type.
-
- :return:
+ """
+ Creates constraint collector that returns List of the given element type.
"""
if group_value_mapping is None:
return NoArgsConstraintCollector(ConstraintCollectors._delegate().toList)
@@ -779,9 +723,8 @@ def to_set(group_value_mapping: Callable[[A, B, C, D], A_]) -> 'QuadConstraintCo
@staticmethod
def to_set(group_value_mapping=None):
- """Creates constraint collector that returns Set of the same element type as the ConstraintStream.
-
- :return:
+ """
+ Creates constraint collector that returns Set of the same element type as the ConstraintStream.
"""
if group_value_mapping is None:
return NoArgsConstraintCollector(ConstraintCollectors._delegate().toSet)
@@ -845,9 +788,8 @@ def to_sorted_set(group_value_mapping: Callable[[A, B, C, D], A_], comparator: C
@staticmethod
def to_sorted_set(group_value_mapping=None, comparator=None):
- """Creates constraint collector that returns SortedSet of the same element type as the ConstraintStream.
-
- :return:
+ """
+ Creates constraint collector that returns SortedSet of the same element type as the ConstraintStream.
"""
if group_value_mapping is None and comparator is None:
return NoArgsConstraintCollector(ConstraintCollectors._delegate().toSortedSet)
@@ -937,9 +879,8 @@ def to_map(key_mapper: Callable[[A, B, C, D], A_], value_mapper: Callable[[A, B]
@staticmethod
def to_map(key_mapper, value_mapper, merge_function_or_set_creator=None):
- """Creates a constraint collector that returns a Map with given keys and values consisting of a Set of mappings.
-
- :return:
+ """
+ Creates a constraint collector that returns a Map with given keys and values consisting of a Set of mappings.
"""
import inspect
if merge_function_or_set_creator is None:
@@ -1035,10 +976,9 @@ def to_sorted_map(key_mapper: Callable[[A, B, C, D], A_], value_mapper: Callable
@staticmethod
def to_sorted_map(key_mapper, value_mapper, merge_function_or_set_creator=None):
- """Creates a constraint collector that returns a SortedMap with given keys and values consisting of a Set of
+ """
+ Creates a constraint collector that returns a SortedMap with given keys and values consisting of a Set of
mappings.
-
- :return:
"""
import inspect
if merge_function_or_set_creator is None:
diff --git a/timefold-solver-python-core/src/main/python/score/_incremental_score_calculator.py b/timefold-solver-python-core/src/main/python/score/_incremental_score_calculator.py
index 2782f27..d60437e 100644
--- a/timefold-solver-python-core/src/main/python/score/_incremental_score_calculator.py
+++ b/timefold-solver-python-core/src/main/python/score/_incremental_score_calculator.py
@@ -5,6 +5,12 @@
@add_java_interface('ai.timefold.solver.core.api.score.calculator.IncrementalScoreCalculator')
class IncrementalScoreCalculator(ABC):
+ """
+ Used for incremental Python `Score` calculation.
+ This is much faster than `easy_score_calculator` but requires much more code to implement too.
+
+ Any implementation is naturally stateful.
+ """
@abstractmethod
def after_entity_added(self, entity) -> None:
...
@@ -49,25 +55,54 @@ def before_variable_changed(self, entity, variable_name: str) -> None:
@abstractmethod
def calculate_score(self):
+ """
+ Notes
+ -----
+ This method is only called if the `Score` cannot be predicted.
+ The `Score` can be predicted for example after an undo move.
+ """
...
@abstractmethod
def reset_working_solution(self, solution) -> None:
+ """
+ Notes
+ -----
+ There are no `before_entity_added` and `after_entity_added`
+ calls for entities that are already present in the working solution.
+ """
...
@add_java_interface('ai.timefold.solver.core.api.score.calculator.ConstraintMatchAwareIncrementalScoreCalculator')
class ConstraintMatchAwareIncrementalScoreCalculator(IncrementalScoreCalculator):
+ """
+ Allows an `IncrementalScoreCalculator`
+ to report `ConstraintMatchTotal` for explaining a score (= which score constraints match for how much)
+ and also for score corruption analysis.
+ """
@abstractmethod
def get_constraint_match_totals(self) -> list:
+ """
+ Notes
+ -----
+ If a constraint is present in the problem but resulted in no matches,
+ it should still be present with a `ConstraintMatchTotal.constraint_match_set` size of 0.
+ """
...
@abstractmethod
- def get_indictment_map(self) -> dict:
+ def get_indictment_map(self) -> dict | None:
+ """
+ Returns ``None`` if it should to be calculated non-incrementally from `get_constraint_match_totals`.
+ """
...
@abstractmethod
def reset_working_solution(self, solution, constraint_match_enabled=False) -> None:
+ """
+ Allows for increased performance because it only tracks if `constraint_match_enabled` is true.
+ """
...
diff --git a/timefold-solver-python-core/src/main/python/score/_joiners.py b/timefold-solver-python-core/src/main/python/score/_joiners.py
index d765b8c..6defa1f 100644
--- a/timefold-solver-python-core/src/main/python/score/_joiners.py
+++ b/timefold-solver-python-core/src/main/python/score/_joiners.py
@@ -168,9 +168,8 @@ def equal(left_mapping: Callable[[A, B, C, D], A_], right_mapping: Callable[[E],
@staticmethod
def equal(mapping_or_left_mapping=None, right_mapping=None):
- """Joins every A and B that share a property.
-
- :return:
+ """
+ Joins every A and B that share a property.
"""
if mapping_or_left_mapping is None and right_mapping is None:
return SamePropertyUniJoiner(Joiners._delegate().equal, lambda a: a)
@@ -198,11 +197,8 @@ def filtering(predicate: Callable[[A, B, C, D, E], bool]) -> 'QuadJoiner[A,B,C,D
@staticmethod
def filtering(predicate):
- """Applies a filter to the joined tuple
-
- :param predicate: the filter to apply
-
- :return:
+ """
+ Applies a filter to the joined tuple.
"""
return FilteringJoiner(Joiners._delegate().filtering, predicate)
@@ -234,9 +230,8 @@ def greater_than(left_mapping: Callable[[A, B, C, D], A_], right_mapping: Callab
@staticmethod
def greater_than(mapping_or_left_mapping, right_mapping=None):
- """Joins every A and B where a value of property on A is greater than the value of a property on B.
-
- :return:
+ """
+ Joins every A and B where a value of property on A is greater than the value of a property on B.
"""
return Joiners._call_comparison_java_joiner(Joiners._delegate().greaterThan, mapping_or_left_mapping,
right_mapping)
@@ -271,9 +266,8 @@ def greater_than_or_equal(left_mapping: Callable[[A, B, C, D], A_], right_mappin
@staticmethod
def greater_than_or_equal(mapping_or_left_mapping, right_mapping=None):
- """Joins every A and B where a value of property on A is greater than or equal to the value of a property on B.
-
- :return:
+ """
+ Joins every A and B where a value of property on A is greater than or equal to the value of a property on B.
"""
return Joiners._call_comparison_java_joiner(Joiners._delegate().greaterThanOrEqual, mapping_or_left_mapping,
right_mapping)
@@ -306,9 +300,8 @@ def less_than(left_mapping: Callable[[A, B, C, D], A_], right_mapping: Callable[
@staticmethod
def less_than(mapping_or_left_mapping, right_mapping=None):
- """Joins every A and B where a value of property on A is less than the value of a property on B.
-
- :return:
+ """
+ Joins every A and B where a value of property on A is less than the value of a property on B.
"""
return Joiners._call_comparison_java_joiner(Joiners._delegate().lessThan, mapping_or_left_mapping,
right_mapping)
@@ -342,9 +335,8 @@ def less_than_or_equal(left_mapping: Callable[[A, B, C, D], A_], right_mapping:
@staticmethod
def less_than_or_equal(mapping_or_left_mapping, right_mapping=None):
- """Joins every A and B where a value of property on A is less than or equal to the value of a property on B.
-
- :return:
+ """
+ Joins every A and B where a value of property on A is less than or equal to the value of a property on B.
"""
return Joiners._call_comparison_java_joiner(Joiners._delegate().lessThanOrEqual, mapping_or_left_mapping,
right_mapping)
@@ -383,10 +375,9 @@ def overlapping(left_start_mapping: Callable[[A, B, C, D], A_], left_end_mapping
@staticmethod
def overlapping(start_mapping_or_left_start_mapping, end_mapping_or_left_end_mapping,
right_start_mapping=None, right_end_mapping=None):
- """Joins every A and B that overlap for an interval which is specified by a start and end property on both A and
+ """
+ Joins every A and B that overlap for an interval which is specified by a start and end property on both A and
B.
-
- :return:
"""
if start_mapping_or_left_start_mapping is None or end_mapping_or_left_end_mapping is None:
raise ValueError
diff --git a/timefold-solver-python-core/src/main/python/score/_score.py b/timefold-solver-python-core/src/main/python/score/_score.py
index 89453d8..3b57c34 100644
--- a/timefold-solver-python-core/src/main/python/score/_score.py
+++ b/timefold-solver-python-core/src/main/python/score/_score.py
@@ -9,6 +9,10 @@ class _SimpleScoreImpl:
def init_score(self) -> int:
return self.initScore()
+ @property
+ def is_solution_initialized(self) -> bool:
+ return self.isSolutionInitialized()
+
@property
def is_feasible(self) -> bool:
return self.isFeasible()
@@ -24,6 +28,10 @@ class _HardSoftScoreImpl:
def init_score(self) -> int:
return self.initScore()
+ @property
+ def is_solution_initialized(self) -> bool:
+ return self.isSolutionInitialized()
+
@property
def is_feasible(self) -> bool:
return self.isFeasible()
@@ -44,6 +52,10 @@ class _HardMediumSoftScoreImpl:
def init_score(self) -> int:
return self.initScore()
+ @property
+ def is_solution_initialized(self) -> bool:
+ return self.isSolutionInitialized()
+
@property
def is_feasible(self) -> bool:
return self.isFeasible()
@@ -67,6 +79,10 @@ class _BendableScoreImpl:
def init_score(self) -> int:
return self.initScore()
+ @property
+ def is_solution_initialized(self) -> bool:
+ return self.isSolutionInitialized()
+
@property
def is_feasible(self) -> bool:
return self.isFeasible()
diff --git a/timefold-solver-python-core/src/main/python/score/_score_analysis.py b/timefold-solver-python-core/src/main/python/score/_score_analysis.py
index 3492921..aee6091 100644
--- a/timefold-solver-python-core/src/main/python/score/_score_analysis.py
+++ b/timefold-solver-python-core/src/main/python/score/_score_analysis.py
@@ -25,22 +25,50 @@
@dataclass(frozen=True, unsafe_hash=True)
class ConstraintRef:
+ """
+ Represents a unique identifier of a constraint.
+ Users should have no need to create instances of this record.
+
+ Attributes
+ ----------
+ package_name : str
+ The constraint package is the namespace of the constraint.
+ When using a `constraint_configuration`, it is equal to the
+ `ConstraintWeight.constraint_package`.
+
+ constraint_name : str
+ The constraint name.
+ It might not be unique, but `constraint_id` is unique.
+ When using a `constraint_configuration`, it is equal to the `ConstraintWeight.constraint_name`.
+ """
package_name: str
constraint_name: str
@property
def constraint_id(self) -> str:
+ """
+ Always derived from packageName and constraintName.
+ """
return f'{self.package_name}/{self.constraint_name}'
@staticmethod
def compose_constraint_id(solution_type_or_package: Union[type, str], constraint_name: str) -> str:
- """Returns the constraint id with the given constraint package and the given name
+ """
+ Returns the constraint id with the given constraint package and the given name
- :param solution_type_or_package: The constraint package, or a class decorated with @planning_solution
+ Parameters
+ ----------
+ solution_type_or_package : type | str
+ the constraint package, or a class decorated with @planning_solution
(for when the constraint is in the default package)
- :param constraint_name: The name of the constraint
- :return: The constraint id with the given name in the default package.
- :rtype: str
+
+ constraint_name : str
+ the name of the constraint
+
+ Returns
+ -------
+ str
+ the constraint id with the given name in the default package
"""
package = solution_type_or_package
if not isinstance(solution_type_or_package, str):
@@ -58,6 +86,13 @@ def _safe_hash(obj: Any) -> int:
@dataclass(frozen=True, eq=True)
class ConstraintMatch(Generic[Score_]):
+ """
+ Retrievable from `ConstraintMatchTotal.constraint_match_set` and
+ `Indictment.constraint_match_set`.
+ This class is comparable for consistent ordering of constraint matches in visualizations.
+ The details of this ordering are unspecified and are subject to change.
+ If possible, prefer using `SolutionManager.analyze` instead.
+ """
constraint_ref: ConstraintRef
justification: Any
indicted_objects: tuple[Any, ...]
@@ -78,6 +113,12 @@ def __hash__(self) -> int:
@dataclass(eq=True)
class ConstraintMatchTotal(Generic[Score_]):
+ """
+ Explains the Score of a `planning_solution`,
+ from the opposite side than `Indictment`.
+ Retrievable from `ScoreExplanation.constraint_match_total_map`.
+ If possible, prefer using `SolutionManager.analyze` instead.
+ """
constraint_ref: ConstraintRef
constraint_match_count: int
constraint_match_set: set[ConstraintMatch]
@@ -99,11 +140,47 @@ def __hash__(self) -> int:
@add_java_interface('ai.timefold.solver.core.api.score.stream.ConstraintJustification')
class ConstraintJustification:
+ """
+ Marker interface for constraint justifications.
+ All classes used as constraint justifications must implement this interface.
+ Implementing classes ("implementations")
+ may decide to implement Comparable to preserve order of instances when displayed in user interfaces,
+ logs etc. This is entirely optional.
+
+ If two instances of this class are equal, they are considered to be the same justification.
+ This matters in case of `SolutionManager.analyze` score analysis where such justifications are grouped together.
+ This situation is likely to occur in case a ConstraintStream produces duplicate tuples,
+ which can be avoided by using `UniConstraintStream.distinct()` or its bi, tri and quad counterparts.
+ Alternatively, some unique ID (such as `uuid.uuid4()`) can be used to distinguish between instances.
+ Score analysis does not diff contents of the implementations;
+ instead it uses equality of the implementations (as defined above) to tell them apart from the outside.
+ For this reason, it is recommended that:
+
+ - The implementations must not use Score for equal and hash codes,
+ as that would prevent diffing from working entirely.
+
+ - The implementations should not store any Score instances,
+ as they would not be diffed, leading to confusion with `MatchAnalysis.score`, which does get diffed.
+
+ If the user wishes to use score analysis,
+ they are required to ensure that the class(es)
+ implementing this interface can be serialized into any format
+ which is supported by the SolutionManager implementation,
+ typically JSON.
+
+ See Also
+ --------
+ ConstraintMatch.justification
+ """
pass
@dataclass(frozen=True, eq=True)
class DefaultConstraintJustification(ConstraintJustification):
+ """
+ Default implementation of `ConstraintJustification`, returned by
+ `ConstraintMatch.justification` unless the user defined a custom justification mapping.
+ """
facts: tuple[Any, ...]
impact: Score_
@@ -146,6 +223,25 @@ def _unwrap_justification_list(justification_list: list[Any]) -> list[Constraint
class Indictment(Generic[Score_]):
+ """
+ Explains the `Score` of a `planning_solution`,
+ from the opposite side than `ConstraintMatchTotal`.
+ Retrievable from `ScoreExplanation.indictment_map`.
+
+ Attributes
+ ----------
+ constraint_match_set: set[ConstraintMatch]
+
+ score: Score_
+ Sum of the constraint_match_set's `ConstraintMatch.score`.
+
+ constraint_match_count: int
+
+ indicted_object : Any
+ The object that was involved in causing the constraints to match.
+ It is part of `ConstraintMatch.indicted_objects` of every `ConstraintMatch`
+ in `constraint_match_set`.
+ """
def __init__(self, delegate: '_JavaIndictment[Score_]'):
self._delegate = delegate
@@ -166,6 +262,22 @@ def indicted_object(self) -> Any:
return unwrap_python_like_object(self._delegate.getIndictedObject())
def get_justification_list(self, justification_type: Type[Justification_] = None) -> list[Justification_]:
+ """
+ Retrieve ConstraintJustification instances associated with ConstraintMatches in `constraint_match_set`.
+ This is equivalent to retrieving `constraint_match_set` and collecting all `ConstraintMatch.justification`
+ objects into a list.
+
+ Parameters
+ ----------
+ justification_type : Type[Justification_], optional
+ If present, only include justifications of the given type in the returned list.
+
+ Returns
+ -------
+ list[Justification_]
+ guaranteed to contain unique instances
+
+ """
if justification_type is None:
justification_list = self._delegate.getJustificationList()
else:
@@ -175,6 +287,45 @@ def get_justification_list(self, justification_type: Type[Justification_] = None
class ScoreExplanation(Generic[Solution_]):
+ """
+ Build by `SolutionManager.explain`
+ to hold `ConstraintMatchTotal`s and `Indictment`s
+ necessary to explain the quality of a particular `Score`.
+
+ For a simplified, faster and JSON-friendly alternative, see `ScoreAnalysis`.
+
+ Attributes
+ ----------
+ solution : Solution_
+ Retrieve the `planning_solution` that the score being explained comes from.
+
+ score : Score
+ Return the `Score` being explained.
+ If the specific Score type used by the `planning_solution`
+ is required, retrieve it from the `solution` attribute.
+
+ summary : str
+ Returns a diagnostic text
+ that explains the solution through the `ConstraintMatch` API
+ to identify which constraints or planning entities cause that score quality.
+
+ In case of an infeasible solution, this can help diagnose the cause of that.
+ Do not parse the return value, its format may change without warning.
+ Instead, to provide this information in a UI or a service,
+ use `constraint_match_total_map` and `indictment_map` and convert those into a domain-specific API.
+
+ constraint_match_total_map : dict[str, ConstraintMatchTotal]
+ Explains the `Score` of the `score` attribute by splitting it up per `Constraint`.
+ The sum of `ConstraintMatchTotal.score` equals the `score` attribute.
+
+ indictment_map: dict[Any, Indictment]
+ Explains the impact of each planning entity or problem fact on the `Score`.
+ An `Indictment` is basically the inverse of a `ConstraintMatchTotal`:
+ it is a Score total for any of the indicted objects.
+
+ The sum of `ConstraintMatchTotal.score` accessible from this `dict`
+ differs from `score` because each `ConstraintMatch.score` is counted for each of the indicted objects.
+ """
_delegate: '_JavaScoreExplanation'
def __init__(self, delegate: '_JavaScoreExplanation'):
@@ -216,6 +367,29 @@ def summary(self) -> str:
return self._delegate.getSummary()
def get_justification_list(self, justification_type: Type[Justification_] = None) -> list[Justification_]:
+ """
+ Explains the `Score` of the `score` attribute for all constraints.
+ The return value of this method is determined by several factors:
+
+ - With Constraint Streams,
+ the user has an option to provide a custom justification mapping, implementing `ConstraintJustification`.
+ If provided, every ConstraintMatch of such constraint will be associated with this custom justification class.
+ Every constraint
+ not associated with a custom justification class will be associated with `DefaultConstraintJustification`.
+
+ - With ConstraintMatchAwareIncrementalScoreCalculator, every `ConstraintMatch`
+ will be associated with the justification class that the user created it with.
+
+ Parameters
+ ----------
+ justification_type : Type[Justification_], optional
+ If present, only include justifications of the given type in the returned list.
+
+ Returns
+ -------
+ list[Justification_]
+ all constraint matches, optionally only those of a given class.
+ """
if justification_type is None:
justification_list = self._delegate.getJustificationList()
else:
@@ -225,6 +399,16 @@ def get_justification_list(self, justification_type: Type[Justification_] = None
class MatchAnalysis(Generic[Score_]):
+ """
+ Users should never create instances of this type directly.
+ It is available transitively via `SolutionManager.analyze`.
+
+ Attributes
+ ----------
+ constraint_ref : ConstraintRef
+ score : Score_
+ justification : ConstraintJustification
+ """
_delegate: '_JavaMatchAnalysis'
def __init__(self, delegate: '_JavaMatchAnalysis'):
@@ -245,6 +429,23 @@ def justification(self) -> ConstraintJustification:
class ConstraintAnalysis(Generic[Score_]):
+ """
+ Users should never create instances of this type directly.
+ It is available transitively via `SolutionManager.analyze`.
+
+ Attributes
+ ----------
+ constraint_ref : ConstraintRef
+ weight : Score_
+ score : Score_
+ matches : list[MatchAnalysis]
+ None if analysis not available;
+ empty if constraint has no matches,
+ but still non-zero constraint weight; non-empty if constraint has matches.
+ This is a list to simplify access to individual elements,
+ but it contains no duplicates just like `set` wouldn't.
+
+ """
_delegate: '_JavaConstraintAnalysis[Score_]'
def __init__(self, delegate: '_JavaConstraintAnalysis[Score_]'):
@@ -279,6 +480,40 @@ def score(self) -> Score_:
class ScoreAnalysis:
+ """
+ Represents the breakdown of a `Score` into individual `ConstraintAnalysis` instances,
+ one for each constraint.
+ Compared to `ScoreExplanation`, this is JSON-friendly and faster to generate.
+
+ In order to be fully serializable to JSON,
+ MatchAnalysis instances must be serializable to JSON
+ and that requires any implementations of `ConstraintJustification` to be serializable to JSON.
+ This is the responsibility of the user.
+
+ For deserialization from JSON, the user needs to provide the deserializer themselves.
+ This is due to the fact that, once the `ScoreAnalysis` is received over the wire,
+ we no longer know which Score type or `ConstraintJustification` type was used.
+ The user has all of that information in their domain model,
+ and so they are the correct party to provide the deserializer.
+
+ Attributes
+ ----------
+ constraint_map : dict[ConstraintRef, ConstraintAnalysis]
+ for each constraint identified by its `constraint_ref`,
+ the `ConstraintAnalysis` that describes the impact of that constraint on the overall score.
+ Constraints are present even if they have no matches,
+ unless their weight is zero; zero-weight constraints are not present.
+ Entries in the map have a stable iteration order; items are ordered first by `ConstraintAnalysis.weight,
+ then by `ConstraintAnalysis.constraint_ref`.
+
+ constraint_analyses : list[ConstraintAnalysis]
+ Individual ConstraintAnalysis instances that make up this ScoreAnalysis.
+
+ Notes
+ -----
+ the constructors of this record are off-limits.
+ We ask users to use exclusively `SolutionManager.analyze` to obtain instances of this record.
+ """
_delegate: '_JavaScoreAnalysis'
def __init__(self, delegate: '_JavaScoreAnalysis'):
diff --git a/timefold-solver-python-core/src/main/python/score/_score_director.py b/timefold-solver-python-core/src/main/python/score/_score_director.py
index e754555..b073030 100644
--- a/timefold-solver-python-core/src/main/python/score/_score_director.py
+++ b/timefold-solver-python-core/src/main/python/score/_score_director.py
@@ -1,4 +1,7 @@
class ScoreDirector:
+ """
+ The `ScoreDirector` holds the working solution and calculates the `Score` for it.
+ """
def __init__(self, delegate):
self._delegate = delegate
@@ -57,12 +60,28 @@ def before_variable_changed(self, entity, variable_name: str) -> None:
self._delegate.beforeVariableChanged(entity, variable_name)
def get_working_solution(self):
+ """
+ The `planning_solution` that is used to calculate the `Score`.
+ Because a `Score` is best calculated incrementally (by deltas),
+ the ScoreDirector needs to be notified when its working solution changes.
+ """
return self._delegate.getWorkingSolution()
def look_up_working_object(self, working_object):
+ """
+ Translates an entity or fact instance (often from another Thread)
+ to this `ScoreDirector`'s internal working instance.
+ Useful for move rebasing and in a `ProblemChange`.
+ Matching uses a `PlanningId` by default.
+ """
return self._delegate.lookUpWorkingObject(working_object)
def look_up_working_object_or_return_none(self, working_object):
+ """
+ As defined by `look_up_working_object`,
+ but doesn't fail fast if no `working_object` was ever added for the `external_object`.
+ It's recommended to use `look_up_working_object` instead, especially in move rebasing code.
+ """
return self._delegate.lookUpWorkingObject(working_object)
def trigger_variable_listeners(self) -> None:
diff --git a/timefold-solver-python-core/src/main/python/test/__init__.py b/timefold-solver-python-core/src/main/python/test/__init__.py
index 06fee3a..5e46310 100644
--- a/timefold-solver-python-core/src/main/python/test/__init__.py
+++ b/timefold-solver-python-core/src/main/python/test/__init__.py
@@ -1,3 +1,21 @@
+"""
+Classes used to test constraints.
+See `testing a constraint stream
+`_.
+
+Examples
+--------
+>>> from timefold.solver.test import ConstraintVerifier
+>>> from domain import Lesson, Room, Timeslot, generate_solver_config
+>>> from constraint import overlapping_timeslots
+>>>
+>>> verifier = ConstraintVerifier.create(generate_solver_config())
+>>> timeslot = Timeslot(...)
+>>> (verifier.verify_that(overlapping_timeslots)
+... .given(Lesson('Amy', Room('A'), timeslot),
+... Lesson('Amy', Room('B'), timeslot))
+... .penalizes_by(1))
+"""
from typing import Callable, Generic, List, Type, TypeVar, TYPE_CHECKING, overload, Union
from .._jpype_type_conversions import PythonBiFunction
@@ -49,17 +67,17 @@ def verify_that(self) -> 'MultiConstraintVerification[Solution_]':
@overload
def verify_that(self, constraint_function: Callable[['ConstraintFactory'], 'Constraint']) -> \
'SingleConstraintVerification[Solution_]':
- """
- Creates a constraint verifier for a given Constraint of the ConstraintProvider.
- :param constraint_function: The constraint to verify
- """
...
def verify_that(self, constraint_function: Callable[['ConstraintFactory'], 'Constraint'] = None):
"""
Creates a constraint verifier for a given Constraint of the ConstraintProvider.
- :param constraint_function: Sometimes None, the constraint to verify. If not provided, all
- constraints will be tested
+
+ Parameters
+ ----------
+ constraint_function : Callable[['ConstraintFactory'], 'Constraint'], optional
+ the constraint to verify.
+ If not provided, all constraints will be tested
"""
if constraint_function is None:
return MultiConstraintVerification(self.delegate.verifyThat())
@@ -76,7 +94,11 @@ def __init__(self, delegate):
def given(self, *facts) -> 'SingleConstraintAssertion':
"""
Set the facts for this assertion
- :param facts: Never None, at least one
+
+ Parameters
+ ----------
+ facts
+ never ``None``, at least one
"""
from ai.timefold.jpyinterpreter import CPythonBackedPythonInterpreter # noqa
from ai.timefold.jpyinterpreter.types import CPythonBackedPythonLikeObject # noqa
@@ -95,7 +117,11 @@ def given(self, *facts) -> 'SingleConstraintAssertion':
def given_solution(self, solution: 'Solution_') -> 'SingleConstraintAssertion':
"""
Set the solution to be used for this assertion
- :param solution: Never None
+
+ Parameters
+ ----------
+ solution
+ never ``None``
"""
from jpyinterpreter import convert_to_java_python_like_object
wrapped_solution = convert_to_java_python_like_object(solution)
@@ -109,7 +135,11 @@ def __init__(self, delegate):
def given(self, *facts) -> 'MultiConstraintAssertion':
"""
Set the facts for this assertion
- :param facts: Never None, at least one
+
+ Parameters
+ ----------
+ facts
+ never ``None``, at least one
"""
from ai.timefold.jpyinterpreter import CPythonBackedPythonInterpreter # noqa
from ai.timefold.jpyinterpreter.types import CPythonBackedPythonLikeObject # noqa
@@ -127,8 +157,12 @@ def given(self, *facts) -> 'MultiConstraintAssertion':
def given_solution(self, solution: 'Solution_') -> 'MultiConstraintAssertion':
"""
- Set the solution to be used for this assertion
- :param solution: Never None
+ Set the solution to be used for this assertion.
+
+ Parameters
+ ----------
+ solution
+ never ``None``
"""
from jpyinterpreter import convert_to_java_python_like_object
wrapped_solution = convert_to_java_python_like_object(solution)
@@ -139,76 +173,27 @@ class SingleConstraintAssertion:
def __init__(self, delegate):
self.delegate = delegate
- @overload
- def penalizes(self) -> None:
- """
- Asserts that the Constraint being tested, given a set of facts, results in any number of penalties.
-
- Ignores the constraint and match weights: it only asserts the number of matches
- For example: if there are two matches with weight of 10 each, this assertion will succeed.
- If there are no matches, it will fail.
-
- :raises AssertionError: when there are no penalties
- """
- ...
-
- @overload
- def penalizes(self, times: int) -> None:
- """
- Asserts that the Constraint being tested, given a set of facts, results in a given number of penalties.
-
- Ignores the constraint and match weights: it only asserts the number of matches
- For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
-
- :param times: the expected number of penalties
- :raises AssertionError: when the expected penalty is not observed
- """
- ...
-
- @overload
- def penalizes(self, message: str) -> None:
- """
- Asserts that the Constraint being tested, given a set of facts, results in any number of penalties.
-
- Ignores the constraint and match weights: it only asserts the number of matches
- For example: if there are two matches with weight of 10 each, this assertion will succeed.
- If there are no matches, it will fail.
-
- :param message: sometimes None, description of the scenario being asserted
-
- :raises AssertionError: when there are no penalties
- """
- ...
-
- @overload
- def penalizes(self, times: int, message: str) -> None:
+ def penalizes(self, times: int = None, message: str = None) -> None:
"""
Asserts that the Constraint being tested, given a set of facts, results in a given number of penalties.
Ignores the constraint and match weights: it only asserts the number of matches
For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
- :param times: the expected number of penalties
- :param message: sometimes None, description of the scenario being asserted
+ Parameters
+ ----------
+ times : int, optional
+ the expected number of penalties.
+ If not provided, it raises an AssertionError when there are no penalties
- :raises AssertionError: when the expected penalty is not observed
- """
- ...
-
- def penalizes(self, times: Union[int, str] = None, message: str = None) -> None:
- """
- Asserts that the Constraint being tested, given a set of facts, results in a given number of penalties.
-
- Ignores the constraint and match weights: it only asserts the number of matches
- For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
-
- :param times: sometimes None, the expected number of penalties. If not provided, it raises an AssertionError
- when there are no penalties
- :param message: sometimes None, description of the scenario being asserted
-
- :raises AssertionError: when the expected penalty is not observed if times is provided, or
- when there are no penalties if times is not provided
+ message : str, optional
+ description of the scenario being asserted
+ Raises
+ ------
+ AssertionError
+ when the expected penalty is not observed if `times` is provided, or
+ when there are no penalties if `times` is not provided
"""
from java.lang import AssertionError as JavaAssertionError # noqa
try:
@@ -223,46 +208,26 @@ def penalizes(self, times: Union[int, str] = None, message: str = None) -> None:
except JavaAssertionError as e:
raise AssertionError(e.getMessage())
- @overload
- def penalizes_by(self, match_weight_total: int) -> None:
+ def penalizes_by(self, match_weight_total: int, message: str = None):
"""
- Asserts that the Constraint being tested, given a set of facts, results in a specific penalty.
+ Asserts that the `Constraint` being tested, given a set of facts, results in a specific penalty.
Ignores the constraint weight: it only asserts the match weights.
For example: a match with a match weight of 10 on a constraint with a constraint weight of -2hard reduces the
score by -20hard. In that case, this assertion checks for 10.
- :param match_weight_total: the expected penalty
- :raises AssertionError: when the expected penalty is not observed
- """
- ...
-
- @overload
- def penalizes_by(self, match_weight_total: int, message: str) -> None:
- """
- Asserts that the Constraint being tested, given a set of facts, results in a specific penalty.
-
- Ignores the constraint weight: it only asserts the match weights.
- For example: a match with a match weight of 10 on a constraint with a constraint weight of -2hard reduces the
- score by -20hard. In that case, this assertion checks for 10.
+ Parameters
+ ----------
+ match_weight_total : int
+ the expected penalty
- :param match_weight_total: the expected penalty
- :param message: sometimes None, description of the scenario being asserted
- :raises AssertionError: when the expected penalty is not observed
- """
- ...
+ message : str, optional
+ description of the scenario being asserted
- def penalizes_by(self, match_weight_total: int, message: str = None):
- """
- Asserts that the Constraint being tested, given a set of facts, results in a specific penalty.
-
- Ignores the constraint weight: it only asserts the match weights.
- For example: a match with a match weight of 10 on a constraint with a constraint weight of -2hard reduces the
- score by -20hard. In that case, this assertion checks for 10.
-
- :param match_weight_total: the expected penalty
- :param message: sometimes None, description of the scenario being asserted
- :raises AssertionError: when the expected penalty is not observed
+ Raises
+ ------
+ AssertionError
+ when the expected penalty is not observed
"""
from java.lang import AssertionError as JavaAssertionError # noqa
try:
@@ -273,60 +238,6 @@ def penalizes_by(self, match_weight_total: int, message: str = None):
except JavaAssertionError as e:
raise AssertionError(e.getMessage())
- @overload
- def rewards(self) -> None:
- """
- Asserts that the Constraint being tested, given a set of facts, results in any number of rewards.
-
- Ignores the constraint and match weights: it only asserts the number of matches
- For example: if there are two matches with weight of 10 each, this assertion will succeed.
- If there are no matches, it will fail.
-
- :raises AssertionError: when there are no rewards
- """
- ...
-
- @overload
- def rewards(self, times: int) -> None:
- """
- Asserts that the Constraint being tested, given a set of facts, results in a given number of rewards.
-
- Ignores the constraint and match weights: it only asserts the number of matches
- For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
-
- :param times: the expected number of rewards
- :raises AssertionError: when the expected reward is not observed
- """
- ...
-
- @overload
- def rewards(self, message: str) -> None:
- """
- Asserts that the Constraint being tested, given a set of facts, results in any number of rewards.
-
- Ignores the constraint and match weights: it only asserts the number of matches
- For example: if there are two matches with weight of 10 each, this assertion will succeed.
- If there are no matches, it will fail.
-
- :param message: sometimes None, description of the scenario being asserted
- :raises AssertionError: when there are no rewards
- """
- ...
-
- @overload
- def rewards(self, times: int, message: str) -> None:
- """
- Asserts that the Constraint being tested, given a set of facts, results in a given number of rewards.
-
- Ignores the constraint and match weights: it only asserts the number of matches
- For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
-
- :param times: the expected number of rewards
- :param message: sometimes None, description of the scenario being asserted
- :raises AssertionError: when the expected reward is not observed
- """
- ...
-
def rewards(self, times: int = None, message: str = None):
"""
Asserts that the Constraint being tested, given a set of facts, results in a given number of rewards.
@@ -334,13 +245,20 @@ def rewards(self, times: int = None, message: str = None):
Ignores the constraint and match weights: it only asserts the number of matches
For example: if there are two matches with weight of 10 each, this assertion will check for 2 matches.
- :param times: sometimes None, the expected number of rewards. If not provided, it raises an AssertionError
- when there are no rewards
- :param message: sometimes None, description of the scenario being asserted
+ Parameters
+ ----------
+ times : int, optional
+ the expected number of rewards.
+ If not provided, it raises an AssertionError when there are no rewards
- :raises AssertionError: when the expected reward is not observed if times is provided, or
- when there are no rewards if times is not provided
+ message : str, optional
+ description of the scenario being asserted
+ Raises
+ ------
+ AssertionError
+ when the expected reward is not observed if times is provided, or
+ when there are no rewards if times is not provided
"""
from java.lang import AssertionError as JavaAssertionError # noqa
try:
@@ -355,36 +273,27 @@ def rewards(self, times: int = None, message: str = None):
except JavaAssertionError as e:
raise AssertionError(e.getMessage())
- @overload
- def rewards_with(self, match_weight_total: int) -> None:
+ def rewards_with(self, match_weight_total: int, message: str = None):
"""
Asserts that the Constraint being tested, given a set of facts, results in a specific reward.
-
Ignores the constraint weight: it only asserts the match weights.
- For example: a match with a match weight of 10 on a constraint with a constraint weight of -2hard reduces the
- score by -20hard. In that case, this assertion checks for 10.
-
- :param match_weight_total: the expected reward
- :raises AssertionError: when the expected reward is not observed
- """
- ...
+ For example: a match with a match weight of 10 on a constraint with a constraint weight of
+ -2hard reduces the score by -20hard.
+ In that case, this assertion checks for 10.
- @overload
- def rewards_with(self, match_weight_total: int, message: str) -> None:
- """
- Asserts that the Constraint being tested, given a set of facts, results in a specific reward.
+ Parameters
+ ----------
+ match_weight_total : int
+ at least 0, expected sum of match weights of matches of the constraint.
- Ignores the constraint weight: it only asserts the match weights.
- For example: a match with a match weight of 10 on a constraint with a constraint weight of -2hard reduces the
- score by -20hard. In that case, this assertion checks for 10.
+ message : str, optional
+ description of the scenario being asserted
- :param match_weight_total: the expected reward
- :param message: sometimes None, description of the scenario being asserted
- :raises AssertionError: when the expected reward is not observed
+ Raises
+ ------
+ AssertionError
+ when the expected reward is not observed
"""
- ...
-
- def rewards_with(self, match_weight_total: int, message: str = None):
from java.lang import AssertionError as JavaAssertionError # noqa
try:
if message is None:
@@ -399,31 +308,22 @@ class MultiConstraintAssertion:
def __init__(self, delegate):
self.delegate = delegate
- @overload
- def scores(self, score: 'Score') -> None:
- """
- Asserts that the ConstraintProvider under test, given a set of facts, results in a specific Score.
- :param score: total score calculated for the given set of facts
- :raises AssertionError: when the expected score does not match the calculated score
+ def scores(self, score: 'Score', message: str = None):
"""
- ...
+ Asserts that the `constraint_provider` under test, given a set of facts, results in a specific `Score`.
- @overload
- def scores(self, score: 'Score', message: str) -> None:
- """
- Asserts that the ConstraintProvider under test, given a set of facts, results in a specific Score.
- :param score: total score calculated for the given set of facts
- :param message: sometimes None, description of the scenario being asserted
- :raises AssertionError: when the expected score does not match the calculated score
- """
- ...
+ Parameters
+ ----------
+ score : Score
+ total score calculated for the given set of facts
- def scores(self, score: 'Score', message: str = None):
- """
- Asserts that the ConstraintProvider under test, given a set of facts, results in a specific Score.
- :param score: total score calculated for the given set of facts
- :param message: sometimes None, description of the scenario being asserted
- :raises AssertionError: when the expected score does not match the calculated score
+ message: str, optional
+ description of the scenario being asserted
+
+ Raises
+ ------
+ AssertionError
+ when the expected score does not match the calculated score
"""
from java.lang import AssertionError as JavaAssertionError # noqa
try: