diff --git a/setup.py b/setup.py index ccd897b..e9087d7 100644 --- a/setup.py +++ b/setup.py @@ -145,7 +145,7 @@ def find_stub_files(stub_root: str): test_suite='tests', python_requires='>=3.10', install_requires=[ - 'JPype1>=1.5.0', + 'JPype1>=1.5.0' ], cmdclass={'build_py': FetchDependencies}, package_data={ diff --git a/tests/test_solution_manager.py b/tests/test_solution_manager.py index 3a71113..82784bf 100644 --- a/tests/test_solution_manager.py +++ b/tests/test_solution_manager.py @@ -3,6 +3,19 @@ from timefold.solver.config import * from timefold.solver.score import * +import inspect +import re + +from ai.timefold.solver.core.api.score import ScoreExplanation as JavaScoreExplanation +from ai.timefold.solver.core.api.score.analysis import ( + ConstraintAnalysis as JavaConstraintAnalysis, + MatchAnalysis as JavaMatchAnalysis, + ScoreAnalysis as JavaScoreAnalysis) +from ai.timefold.solver.core.api.score.constraint import Indictment as JavaIndictment +from ai.timefold.solver.core.api.score.constraint import (ConstraintRef as JavaConstraintRef, + ConstraintMatch as JavaConstraintMatch, + ConstraintMatchTotal as JavaConstraintMatchTotal) + from dataclasses import dataclass, field from typing import Annotated, List @@ -18,8 +31,8 @@ class Entity: def my_constraints(constraint_factory: ConstraintFactory): return [ constraint_factory.for_each(Entity) - .reward(SimpleScore.ONE, lambda entity: entity.value) - .as_constraint('package', 'Maximize Value'), + .reward(SimpleScore.ONE, lambda entity: entity.value) + .as_constraint('package', 'Maximize Value'), ] @@ -127,6 +140,27 @@ def assert_score_analysis(problem: Solution, score_analysis: ScoreAnalysis): assert_constraint_analysis(problem, constraint_analysis) +def assert_score_analysis_summary(score_analysis: ScoreAnalysis): + summary = score_analysis.summary + assert "Explanation of score (3):" in summary + assert "Constraint matches:" in summary + assert "3: constraint (Maximize Value) has 3 matches:" in summary + assert "1: justified with" in summary + + summary_str = str(score_analysis) + assert summary == summary_str + + match = score_analysis.constraint_analyses[0] + match_summary = match.summary + assert "Explanation of score (3):" in match_summary + assert "Constraint matches:" in match_summary + assert "3: constraint (Maximize Value) has 3 matches:" in match_summary + assert "1: justified with" in match_summary + + match_summary_str = str(match) + assert match_summary == match_summary_str + + def assert_solution_manager(solution_manager: SolutionManager[Solution]): problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1)], [1, 2, 3]) assert problem.score is None @@ -140,6 +174,9 @@ def assert_solution_manager(solution_manager: SolutionManager[Solution]): score_analysis = solution_manager.analyze(problem) assert_score_analysis(problem, score_analysis) + score_analysis = solution_manager.analyze(problem) + assert_score_analysis_summary(score_analysis) + def test_solver_manager_score_manager(): with SolverManager.create(SolverFactory.create(solver_config)) as solver_manager: @@ -148,3 +185,127 @@ def test_solver_manager_score_manager(): def test_solver_factory_score_manager(): assert_solution_manager(SolutionManager.create(SolverFactory.create(solver_config))) + + +def test_score_manager_solution_initialization(): + solution_manager = SolutionManager.create(SolverFactory.create(solver_config)) + problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1)], [1, 2, 3]) + score_analysis = solution_manager.analyze(problem) + assert score_analysis.is_solution_initialized + + second_problem: Solution = Solution([Entity('A', None), Entity('B', None), Entity('C', None)], [1, 2, 3]) + second_score_analysis = solution_manager.analyze(second_problem) + assert not second_score_analysis.is_solution_initialized + + +def test_score_manager_diff(): + solution_manager = SolutionManager.create(SolverFactory.create(solver_config)) + problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1)], [1, 2, 3]) + score_analysis = solution_manager.analyze(problem) + second_problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1), Entity('D', 1)], [1, 2, 3]) + second_score_analysis = solution_manager.analyze(second_problem) + diff = score_analysis.diff(second_score_analysis) + assert diff.score.score == -1 + + diff_operation = score_analysis - second_score_analysis + assert diff_operation.score.score == -1 + + constraint_analyses = score_analysis.constraint_analyses + assert len(constraint_analyses) == 1 + + +def test_score_manager_constraint_analysis_map(): + solution_manager = SolutionManager.create(SolverFactory.create(solver_config)) + problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1)], [1, 2, 3]) + score_analysis = solution_manager.analyze(problem) + constraints = score_analysis.constraint_analyses + assert len(constraints) == 1 + + constraint_analysis = score_analysis.constraint_analysis('package', 'Maximize Value') + assert constraint_analysis.constraint_name == 'Maximize Value' + + constraint_analysis = score_analysis.constraint_analysis(ConstraintRef('package', 'Maximize Value')) + assert constraint_analysis.constraint_name == 'Maximize Value' + assert constraint_analysis.match_count == 3 + + +def test_score_manager_constraint_ref(): + constraint_ref = ConstraintRef.parse_id('package/Maximize Value') + + assert constraint_ref.package_name == 'package' + assert constraint_ref.constraint_name == 'Maximize Value' + + +ignored_java_functions = { + 'equals', + 'getClass', + 'hashCode', + 'notify', + 'notifyAll', + 'toString', + 'wait', + 'compareTo', +} + +ignored_java_functions_per_class = { + 'Indictment': {'getJustification'}, # deprecated + 'ConstraintRef': {'of', 'packageName', 'constraintName'}, # built-in constructor and properties with @dataclass + 'ConstraintAnalysis': {'summarize'}, # using summary instead + 'ScoreAnalysis': {'summarize'}, # using summary instead + 'ConstraintMatch': { + 'getConstraintRef', # built-in constructor and properties with @dataclass + 'getConstraintPackage', # deprecated + 'getConstraintName', # deprecated + 'getConstraintId', # deprecated + 'getJustificationList', # deprecated + 'getJustification', # built-in constructor and properties with @dataclass + 'getScore', # built-in constructor and properties with @dataclass + 'getIndictedObjectList', # built-in constructor and properties with @dataclass + }, + 'ConstraintMatchTotal': { + 'getConstraintRef', # built-in constructor and properties with @dataclass + 'composeConstraintId', # deprecated + 'getConstraintPackage', # deprecated + 'getConstraintName', # deprecated + 'getConstraintId', # deprecated + 'getConstraintMatchCount', # built-in constructor and properties with @dataclass + 'getConstraintMatchSet', # built-in constructor and properties with @dataclass + 'getConstraintWeight', # built-in constructor and properties with @dataclass + 'getScore', # built-in constructor and properties with @dataclass + }, +} + + +def test_has_all_methods(): + missing = [] + for python_type, java_type in ((ScoreExplanation, JavaScoreExplanation), + (ScoreAnalysis, JavaScoreAnalysis), + (ConstraintAnalysis, JavaConstraintAnalysis), + (ScoreExplanation, JavaScoreExplanation), + (ConstraintMatch, JavaConstraintMatch), + (ConstraintMatchTotal, JavaConstraintMatchTotal), + (ConstraintRef, JavaConstraintRef), + (Indictment, JavaIndictment)): + type_name = python_type.__name__ + ignored_java_functions_type = ignored_java_functions_per_class[ + type_name] if type_name in ignored_java_functions_per_class else {} + + for function_name, function_impl in inspect.getmembers(java_type, inspect.isfunction): + if function_name in ignored_java_functions or function_name in ignored_java_functions_type: + continue + + snake_case_name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', function_name) + snake_case_name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', snake_case_name).lower() + snake_case_name_without_prefix = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', + function_name[3:] if function_name.startswith( + "get") else function_name) + snake_case_name_without_prefix = re.sub('([a-z0-9])([A-Z])', r'\1_\2', + snake_case_name_without_prefix).lower() + if not hasattr(python_type, snake_case_name) and not hasattr(python_type, snake_case_name_without_prefix): + missing.append((java_type, python_type, snake_case_name)) + + if missing: + assertion_msg = '' + for java_type, python_type, snake_case_name in missing: + assertion_msg += f'{python_type} is missing a method ({snake_case_name}) from java_type ({java_type}).)\n' + raise AssertionError(assertion_msg) 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 06b1874..5288f8f 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 @@ -1,9 +1,10 @@ from .._timefold_java_interop import get_class from .._jpype_type_conversions import to_python_score +from .._timefold_java_interop import _java_score_mapping_dict from _jpyinterpreter import unwrap_python_like_object, add_java_interface from dataclasses import dataclass -from typing import TypeVar, Generic, Union, TYPE_CHECKING, Any, cast, Optional, Type +from typing import overload, TypeVar, Generic, Union, TYPE_CHECKING, Any, cast, Optional, Type if TYPE_CHECKING: # These imports require a JVM to be running, so only import if type checking @@ -41,17 +42,27 @@ class ConstraintRef: 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`. + + constraint_id : str + Always derived from `packageName` and `constraintName`. """ 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 parse_id(constraint_id: str): + slash_index = constraint_id.rfind('/') + if slash_index == -1: + raise ValueError( + f'The constraint_id {constraint_id} is invalid as it does not contain a package separator \'/\'.') + package_name = constraint_id[:slash_index] + constraint_name = constraint_id[slash_index + 1:] + return ConstraintRef(package_name, constraint_name) + @staticmethod def compose_constraint_id(solution_type_or_package: Union[type, str], constraint_name: str) -> str: """ @@ -77,6 +88,10 @@ def compose_constraint_id(solution_type_or_package: Union[type, str], constraint return ConstraintRef(package_name=package, constraint_name=constraint_name).constraint_id + def _to_java(self): + from ai.timefold.solver.core.api.score.constraint import ConstraintRef as JavaConstraintRef + return JavaConstraintRef.of(self.package_name, self.constraint_name) + def _safe_hash(obj: Any) -> int: try: @@ -200,7 +215,7 @@ def _map_constraint_match_set(constraint_match_set: set['_JavaConstraintMatch']) .getConstraintRef().constraintName()), justification=_unwrap_justification(constraint_match.getJustification()), indicted_objects=tuple([unwrap_python_like_object(indicted) - for indicted in cast(list, constraint_match.getIndictedObjectList())]), + for indicted in cast(list, constraint_match.getIndictedObjectList())]), score=to_python_score(constraint_match.getScore()) ) for constraint_match in constraint_match_set @@ -213,7 +228,7 @@ def _unwrap_justification(justification: Any) -> ConstraintJustification: if isinstance(justification, _JavaDefaultConstraintJustification): fact_list = justification.getFacts() return DefaultConstraintJustification(facts=tuple([unwrap_python_like_object(fact) - for fact in cast(list, fact_list)]), + for fact in cast(list, fact_list)]), impact=to_python_score(justification.getImpact())) else: return unwrap_python_like_object(justification) @@ -242,7 +257,9 @@ class Indictment(Generic[Score_]): 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 @@ -445,7 +462,11 @@ class ConstraintAnalysis(Generic[Score_]): 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. - + summary : str + Returns a diagnostic text + that explains part of the score quality through the ConstraintAnalysis API. + match_count : int + Return the match count of the constraint. """ _delegate: '_JavaConstraintAnalysis[Score_]' @@ -453,6 +474,9 @@ def __init__(self, delegate: '_JavaConstraintAnalysis[Score_]'): self._delegate = delegate delegate.constraintRef() + def __str__(self): + return self.summary + @property def constraint_ref(self) -> ConstraintRef: return ConstraintRef(package_name=self._delegate.constraintRef().packageName(), @@ -475,10 +499,18 @@ def matches(self) -> list[MatchAnalysis[Score_]]: return [MatchAnalysis(match_analysis) for match_analysis in cast(list['_JavaMatchAnalysis[Score_]'], self._delegate.matches())] + @property + def match_count(self) -> int: + return self._delegate.matchCount() + @property def score(self) -> Score_: return to_python_score(self._delegate.score()) + @property + def summary(self) -> str: + return self._delegate.summarize() + class ScoreAnalysis: """ @@ -510,6 +542,20 @@ class ScoreAnalysis: constraint_analyses : list[ConstraintAnalysis] Individual ConstraintAnalysis instances that make up this ScoreAnalysis. + summary : str + Returns a diagnostic text that explains the solution through the `ConstraintAnalysis` API to identify which + Constraints cause that score quality. + The string is built fresh every time the method is called. + + 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, provide this information in a UI or a service, + use `constraintAnalyses()` + and convert those into a domain-specific API. + + is_solution_initialized : bool + Notes ----- the constructors of this record are off-limits. @@ -520,6 +566,12 @@ class ScoreAnalysis: def __init__(self, delegate: '_JavaScoreAnalysis'): self._delegate = delegate + def __str__(self): + return self.summary + + def __sub__(self, other): + return self.diff(other) + @property def score(self) -> 'Score': return to_python_score(self._delegate.score()) @@ -541,6 +593,73 @@ def constraint_analyses(self) -> list[ConstraintAnalysis]: list['_JavaConstraintAnalysis[Score]'], self._delegate.constraintAnalyses()) ] + @overload + def constraint_analysis(self, constraint_package: str, constraint_name: str) -> ConstraintAnalysis: + ... + + @overload + def constraint_analysis(self, constraint_ref: 'ConstraintRef') -> ConstraintAnalysis: + ... + + def constraint_analysis(self, *args) -> ConstraintAnalysis: + """ + Performs a lookup on `constraint_map`. + + Parameters + ---------- + *args: *tuple[str, str] | *tuple[ConstraintRef] + Either two strings or a single ConstraintRef can be passed as positional arguments. + If two strings are passed, they are taken to be the constraint package and constraint name, respectively. + If a ConstraintRef is passed, it is used to perform the lookup. + + Returns + ------- + ConstraintAnalysis + None if no constraint matches of such constraint are present + """ + if len(args) == 1: + return ConstraintAnalysis(self._delegate.getConstraintAnalysis(args[0]._to_java())) + else: + return ConstraintAnalysis(self._delegate.getConstraintAnalysis(args[0], args[1])) + + @property + def summary(self) -> str: + return self._delegate.summarize() + + @property + def is_solution_initialized(self) -> bool: + return self._delegate.isSolutionInitialized() + + def diff(self, other: 'ScoreAnalysis') -> 'ScoreAnalysis': + """ + Compare this `ScoreAnalysis to another `ScoreAnalysis` + and retrieve the difference between them. + The comparison is in the direction of `this - other`. + + Example: if `this` has a score of 100 and `other` has a score of 90, + the returned score will be 10. + If this and other were inverted, the score would have been -10. + The same applies to all other properties of `ScoreAnalysis`. + + In order to properly diff `MatchAnalysis` against each other, + we rely on the user implementing `ConstraintJustification` equality correctly. + In other words, the diff will consider two justifications equal if the user says they are equal, + and it expects the hash code to be consistent with equals. + + If one `ScoreAnalysis` provides `MatchAnalysis` and the other doesn't, exception is thrown. + Such `ScoreAnalysis` instances are mutually incompatible. + + Parameters + ---------- + other : ScoreAnalysis + + Returns + ------- + ScoreExplanation + The `ScoreAnalysis` corresponding to the diff. + """ + return ScoreAnalysis(self._delegate.diff(other._delegate)) + __all__ = ['ScoreExplanation', 'ConstraintRef', 'ConstraintMatch', 'ConstraintMatchTotal',