Skip to content
This repository was archived by the owner on Jul 17, 2024. It is now read-only.

Commit 4396e2d

Browse files
authoredJul 5, 2024··
feat: Improve ScoreAnalysis debug information (#105)
1 parent 81bbd40 commit 4396e2d

File tree

3 files changed

+290
-10
lines changed

3 files changed

+290
-10
lines changed
 

‎setup.py

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -145,7 +145,7 @@ def find_stub_files(stub_root: str):
145145
test_suite='tests',
146146
python_requires='>=3.10',
147147
install_requires=[
148-
'JPype1>=1.5.0',
148+
'JPype1>=1.5.0'
149149
],
150150
cmdclass={'build_py': FetchDependencies},
151151
package_data={

‎tests/test_solution_manager.py

Lines changed: 163 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,19 @@
33
from timefold.solver.config import *
44
from timefold.solver.score import *
55

6+
import inspect
7+
import re
8+
9+
from ai.timefold.solver.core.api.score import ScoreExplanation as JavaScoreExplanation
10+
from ai.timefold.solver.core.api.score.analysis import (
11+
ConstraintAnalysis as JavaConstraintAnalysis,
12+
MatchAnalysis as JavaMatchAnalysis,
13+
ScoreAnalysis as JavaScoreAnalysis)
14+
from ai.timefold.solver.core.api.score.constraint import Indictment as JavaIndictment
15+
from ai.timefold.solver.core.api.score.constraint import (ConstraintRef as JavaConstraintRef,
16+
ConstraintMatch as JavaConstraintMatch,
17+
ConstraintMatchTotal as JavaConstraintMatchTotal)
18+
619
from dataclasses import dataclass, field
720
from typing import Annotated, List
821

@@ -18,8 +31,8 @@ class Entity:
1831
def my_constraints(constraint_factory: ConstraintFactory):
1932
return [
2033
constraint_factory.for_each(Entity)
21-
.reward(SimpleScore.ONE, lambda entity: entity.value)
22-
.as_constraint('package', 'Maximize Value'),
34+
.reward(SimpleScore.ONE, lambda entity: entity.value)
35+
.as_constraint('package', 'Maximize Value'),
2336
]
2437

2538

@@ -127,6 +140,27 @@ def assert_score_analysis(problem: Solution, score_analysis: ScoreAnalysis):
127140
assert_constraint_analysis(problem, constraint_analysis)
128141

129142

143+
def assert_score_analysis_summary(score_analysis: ScoreAnalysis):
144+
summary = score_analysis.summary
145+
assert "Explanation of score (3):" in summary
146+
assert "Constraint matches:" in summary
147+
assert "3: constraint (Maximize Value) has 3 matches:" in summary
148+
assert "1: justified with" in summary
149+
150+
summary_str = str(score_analysis)
151+
assert summary == summary_str
152+
153+
match = score_analysis.constraint_analyses[0]
154+
match_summary = match.summary
155+
assert "Explanation of score (3):" in match_summary
156+
assert "Constraint matches:" in match_summary
157+
assert "3: constraint (Maximize Value) has 3 matches:" in match_summary
158+
assert "1: justified with" in match_summary
159+
160+
match_summary_str = str(match)
161+
assert match_summary == match_summary_str
162+
163+
130164
def assert_solution_manager(solution_manager: SolutionManager[Solution]):
131165
problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1)], [1, 2, 3])
132166
assert problem.score is None
@@ -140,6 +174,9 @@ def assert_solution_manager(solution_manager: SolutionManager[Solution]):
140174
score_analysis = solution_manager.analyze(problem)
141175
assert_score_analysis(problem, score_analysis)
142176

177+
score_analysis = solution_manager.analyze(problem)
178+
assert_score_analysis_summary(score_analysis)
179+
143180

144181
def test_solver_manager_score_manager():
145182
with SolverManager.create(SolverFactory.create(solver_config)) as solver_manager:
@@ -148,3 +185,127 @@ def test_solver_manager_score_manager():
148185

149186
def test_solver_factory_score_manager():
150187
assert_solution_manager(SolutionManager.create(SolverFactory.create(solver_config)))
188+
189+
190+
def test_score_manager_solution_initialization():
191+
solution_manager = SolutionManager.create(SolverFactory.create(solver_config))
192+
problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1)], [1, 2, 3])
193+
score_analysis = solution_manager.analyze(problem)
194+
assert score_analysis.is_solution_initialized
195+
196+
second_problem: Solution = Solution([Entity('A', None), Entity('B', None), Entity('C', None)], [1, 2, 3])
197+
second_score_analysis = solution_manager.analyze(second_problem)
198+
assert not second_score_analysis.is_solution_initialized
199+
200+
201+
def test_score_manager_diff():
202+
solution_manager = SolutionManager.create(SolverFactory.create(solver_config))
203+
problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1)], [1, 2, 3])
204+
score_analysis = solution_manager.analyze(problem)
205+
second_problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1), Entity('D', 1)], [1, 2, 3])
206+
second_score_analysis = solution_manager.analyze(second_problem)
207+
diff = score_analysis.diff(second_score_analysis)
208+
assert diff.score.score == -1
209+
210+
diff_operation = score_analysis - second_score_analysis
211+
assert diff_operation.score.score == -1
212+
213+
constraint_analyses = score_analysis.constraint_analyses
214+
assert len(constraint_analyses) == 1
215+
216+
217+
def test_score_manager_constraint_analysis_map():
218+
solution_manager = SolutionManager.create(SolverFactory.create(solver_config))
219+
problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1)], [1, 2, 3])
220+
score_analysis = solution_manager.analyze(problem)
221+
constraints = score_analysis.constraint_analyses
222+
assert len(constraints) == 1
223+
224+
constraint_analysis = score_analysis.constraint_analysis('package', 'Maximize Value')
225+
assert constraint_analysis.constraint_name == 'Maximize Value'
226+
227+
constraint_analysis = score_analysis.constraint_analysis(ConstraintRef('package', 'Maximize Value'))
228+
assert constraint_analysis.constraint_name == 'Maximize Value'
229+
assert constraint_analysis.match_count == 3
230+
231+
232+
def test_score_manager_constraint_ref():
233+
constraint_ref = ConstraintRef.parse_id('package/Maximize Value')
234+
235+
assert constraint_ref.package_name == 'package'
236+
assert constraint_ref.constraint_name == 'Maximize Value'
237+
238+
239+
ignored_java_functions = {
240+
'equals',
241+
'getClass',
242+
'hashCode',
243+
'notify',
244+
'notifyAll',
245+
'toString',
246+
'wait',
247+
'compareTo',
248+
}
249+
250+
ignored_java_functions_per_class = {
251+
'Indictment': {'getJustification'}, # deprecated
252+
'ConstraintRef': {'of', 'packageName', 'constraintName'}, # built-in constructor and properties with @dataclass
253+
'ConstraintAnalysis': {'summarize'}, # using summary instead
254+
'ScoreAnalysis': {'summarize'}, # using summary instead
255+
'ConstraintMatch': {
256+
'getConstraintRef', # built-in constructor and properties with @dataclass
257+
'getConstraintPackage', # deprecated
258+
'getConstraintName', # deprecated
259+
'getConstraintId', # deprecated
260+
'getJustificationList', # deprecated
261+
'getJustification', # built-in constructor and properties with @dataclass
262+
'getScore', # built-in constructor and properties with @dataclass
263+
'getIndictedObjectList', # built-in constructor and properties with @dataclass
264+
},
265+
'ConstraintMatchTotal': {
266+
'getConstraintRef', # built-in constructor and properties with @dataclass
267+
'composeConstraintId', # deprecated
268+
'getConstraintPackage', # deprecated
269+
'getConstraintName', # deprecated
270+
'getConstraintId', # deprecated
271+
'getConstraintMatchCount', # built-in constructor and properties with @dataclass
272+
'getConstraintMatchSet', # built-in constructor and properties with @dataclass
273+
'getConstraintWeight', # built-in constructor and properties with @dataclass
274+
'getScore', # built-in constructor and properties with @dataclass
275+
},
276+
}
277+
278+
279+
def test_has_all_methods():
280+
missing = []
281+
for python_type, java_type in ((ScoreExplanation, JavaScoreExplanation),
282+
(ScoreAnalysis, JavaScoreAnalysis),
283+
(ConstraintAnalysis, JavaConstraintAnalysis),
284+
(ScoreExplanation, JavaScoreExplanation),
285+
(ConstraintMatch, JavaConstraintMatch),
286+
(ConstraintMatchTotal, JavaConstraintMatchTotal),
287+
(ConstraintRef, JavaConstraintRef),
288+
(Indictment, JavaIndictment)):
289+
type_name = python_type.__name__
290+
ignored_java_functions_type = ignored_java_functions_per_class[
291+
type_name] if type_name in ignored_java_functions_per_class else {}
292+
293+
for function_name, function_impl in inspect.getmembers(java_type, inspect.isfunction):
294+
if function_name in ignored_java_functions or function_name in ignored_java_functions_type:
295+
continue
296+
297+
snake_case_name = re.sub('(.)([A-Z][a-z]+)', r'\1_\2', function_name)
298+
snake_case_name = re.sub('([a-z0-9])([A-Z])', r'\1_\2', snake_case_name).lower()
299+
snake_case_name_without_prefix = re.sub('(.)([A-Z][a-z]+)', r'\1_\2',
300+
function_name[3:] if function_name.startswith(
301+
"get") else function_name)
302+
snake_case_name_without_prefix = re.sub('([a-z0-9])([A-Z])', r'\1_\2',
303+
snake_case_name_without_prefix).lower()
304+
if not hasattr(python_type, snake_case_name) and not hasattr(python_type, snake_case_name_without_prefix):
305+
missing.append((java_type, python_type, snake_case_name))
306+
307+
if missing:
308+
assertion_msg = ''
309+
for java_type, python_type, snake_case_name in missing:
310+
assertion_msg += f'{python_type} is missing a method ({snake_case_name}) from java_type ({java_type}).)\n'
311+
raise AssertionError(assertion_msg)

‎timefold-solver-python-core/src/main/python/score/_score_analysis.py

Lines changed: 126 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,10 @@
11
from .._timefold_java_interop import get_class
22
from .._jpype_type_conversions import to_python_score
3+
from .._timefold_java_interop import _java_score_mapping_dict
34
from _jpyinterpreter import unwrap_python_like_object, add_java_interface
45
from dataclasses import dataclass
56

6-
from typing import TypeVar, Generic, Union, TYPE_CHECKING, Any, cast, Optional, Type
7+
from typing import overload, TypeVar, Generic, Union, TYPE_CHECKING, Any, cast, Optional, Type
78

89
if TYPE_CHECKING:
910
# These imports require a JVM to be running, so only import if type checking
@@ -41,17 +42,27 @@ class ConstraintRef:
4142
The constraint name.
4243
It might not be unique, but `constraint_id` is unique.
4344
When using a `constraint_configuration`, it is equal to the `ConstraintWeight.constraint_name`.
45+
46+
constraint_id : str
47+
Always derived from `packageName` and `constraintName`.
4448
"""
4549
package_name: str
4650
constraint_name: str
4751

4852
@property
4953
def constraint_id(self) -> str:
50-
"""
51-
Always derived from packageName and constraintName.
52-
"""
5354
return f'{self.package_name}/{self.constraint_name}'
5455

56+
@staticmethod
57+
def parse_id(constraint_id: str):
58+
slash_index = constraint_id.rfind('/')
59+
if slash_index == -1:
60+
raise ValueError(
61+
f'The constraint_id {constraint_id} is invalid as it does not contain a package separator \'/\'.')
62+
package_name = constraint_id[:slash_index]
63+
constraint_name = constraint_id[slash_index + 1:]
64+
return ConstraintRef(package_name, constraint_name)
65+
5566
@staticmethod
5667
def compose_constraint_id(solution_type_or_package: Union[type, str], constraint_name: str) -> str:
5768
"""
@@ -77,6 +88,10 @@ def compose_constraint_id(solution_type_or_package: Union[type, str], constraint
7788
return ConstraintRef(package_name=package,
7889
constraint_name=constraint_name).constraint_id
7990

91+
def _to_java(self):
92+
from ai.timefold.solver.core.api.score.constraint import ConstraintRef as JavaConstraintRef
93+
return JavaConstraintRef.of(self.package_name, self.constraint_name)
94+
8095

8196
def _safe_hash(obj: Any) -> int:
8297
try:
@@ -200,7 +215,7 @@ def _map_constraint_match_set(constraint_match_set: set['_JavaConstraintMatch'])
200215
.getConstraintRef().constraintName()),
201216
justification=_unwrap_justification(constraint_match.getJustification()),
202217
indicted_objects=tuple([unwrap_python_like_object(indicted)
203-
for indicted in cast(list, constraint_match.getIndictedObjectList())]),
218+
for indicted in cast(list, constraint_match.getIndictedObjectList())]),
204219
score=to_python_score(constraint_match.getScore())
205220
)
206221
for constraint_match in constraint_match_set
@@ -213,7 +228,7 @@ def _unwrap_justification(justification: Any) -> ConstraintJustification:
213228
if isinstance(justification, _JavaDefaultConstraintJustification):
214229
fact_list = justification.getFacts()
215230
return DefaultConstraintJustification(facts=tuple([unwrap_python_like_object(fact)
216-
for fact in cast(list, fact_list)]),
231+
for fact in cast(list, fact_list)]),
217232
impact=to_python_score(justification.getImpact()))
218233
else:
219234
return unwrap_python_like_object(justification)
@@ -242,7 +257,9 @@ class Indictment(Generic[Score_]):
242257
The object that was involved in causing the constraints to match.
243258
It is part of `ConstraintMatch.indicted_objects` of every `ConstraintMatch`
244259
in `constraint_match_set`.
260+
245261
"""
262+
246263
def __init__(self, delegate: '_JavaIndictment[Score_]'):
247264
self._delegate = delegate
248265

@@ -445,14 +462,21 @@ class ConstraintAnalysis(Generic[Score_]):
445462
but still non-zero constraint weight; non-empty if constraint has matches.
446463
This is a list to simplify access to individual elements,
447464
but it contains no duplicates just like `set` wouldn't.
448-
465+
summary : str
466+
Returns a diagnostic text
467+
that explains part of the score quality through the ConstraintAnalysis API.
468+
match_count : int
469+
Return the match count of the constraint.
449470
"""
450471
_delegate: '_JavaConstraintAnalysis[Score_]'
451472

452473
def __init__(self, delegate: '_JavaConstraintAnalysis[Score_]'):
453474
self._delegate = delegate
454475
delegate.constraintRef()
455476

477+
def __str__(self):
478+
return self.summary
479+
456480
@property
457481
def constraint_ref(self) -> ConstraintRef:
458482
return ConstraintRef(package_name=self._delegate.constraintRef().packageName(),
@@ -475,10 +499,18 @@ def matches(self) -> list[MatchAnalysis[Score_]]:
475499
return [MatchAnalysis(match_analysis)
476500
for match_analysis in cast(list['_JavaMatchAnalysis[Score_]'], self._delegate.matches())]
477501

502+
@property
503+
def match_count(self) -> int:
504+
return self._delegate.matchCount()
505+
478506
@property
479507
def score(self) -> Score_:
480508
return to_python_score(self._delegate.score())
481509

510+
@property
511+
def summary(self) -> str:
512+
return self._delegate.summarize()
513+
482514

483515
class ScoreAnalysis:
484516
"""
@@ -510,6 +542,20 @@ class ScoreAnalysis:
510542
constraint_analyses : list[ConstraintAnalysis]
511543
Individual ConstraintAnalysis instances that make up this ScoreAnalysis.
512544
545+
summary : str
546+
Returns a diagnostic text that explains the solution through the `ConstraintAnalysis` API to identify which
547+
Constraints cause that score quality.
548+
The string is built fresh every time the method is called.
549+
550+
In case of an infeasible solution, this can help diagnose the cause of that.
551+
552+
Do not parse the return value, its format may change without warning.
553+
Instead, provide this information in a UI or a service,
554+
use `constraintAnalyses()`
555+
and convert those into a domain-specific API.
556+
557+
is_solution_initialized : bool
558+
513559
Notes
514560
-----
515561
the constructors of this record are off-limits.
@@ -520,6 +566,12 @@ class ScoreAnalysis:
520566
def __init__(self, delegate: '_JavaScoreAnalysis'):
521567
self._delegate = delegate
522568

569+
def __str__(self):
570+
return self.summary
571+
572+
def __sub__(self, other):
573+
return self.diff(other)
574+
523575
@property
524576
def score(self) -> 'Score':
525577
return to_python_score(self._delegate.score())
@@ -541,6 +593,73 @@ def constraint_analyses(self) -> list[ConstraintAnalysis]:
541593
list['_JavaConstraintAnalysis[Score]'], self._delegate.constraintAnalyses())
542594
]
543595

596+
@overload
597+
def constraint_analysis(self, constraint_package: str, constraint_name: str) -> ConstraintAnalysis:
598+
...
599+
600+
@overload
601+
def constraint_analysis(self, constraint_ref: 'ConstraintRef') -> ConstraintAnalysis:
602+
...
603+
604+
def constraint_analysis(self, *args) -> ConstraintAnalysis:
605+
"""
606+
Performs a lookup on `constraint_map`.
607+
608+
Parameters
609+
----------
610+
*args: *tuple[str, str] | *tuple[ConstraintRef]
611+
Either two strings or a single ConstraintRef can be passed as positional arguments.
612+
If two strings are passed, they are taken to be the constraint package and constraint name, respectively.
613+
If a ConstraintRef is passed, it is used to perform the lookup.
614+
615+
Returns
616+
-------
617+
ConstraintAnalysis
618+
None if no constraint matches of such constraint are present
619+
"""
620+
if len(args) == 1:
621+
return ConstraintAnalysis(self._delegate.getConstraintAnalysis(args[0]._to_java()))
622+
else:
623+
return ConstraintAnalysis(self._delegate.getConstraintAnalysis(args[0], args[1]))
624+
625+
@property
626+
def summary(self) -> str:
627+
return self._delegate.summarize()
628+
629+
@property
630+
def is_solution_initialized(self) -> bool:
631+
return self._delegate.isSolutionInitialized()
632+
633+
def diff(self, other: 'ScoreAnalysis') -> 'ScoreAnalysis':
634+
"""
635+
Compare this `ScoreAnalysis to another `ScoreAnalysis`
636+
and retrieve the difference between them.
637+
The comparison is in the direction of `this - other`.
638+
639+
Example: if `this` has a score of 100 and `other` has a score of 90,
640+
the returned score will be 10.
641+
If this and other were inverted, the score would have been -10.
642+
The same applies to all other properties of `ScoreAnalysis`.
643+
644+
In order to properly diff `MatchAnalysis` against each other,
645+
we rely on the user implementing `ConstraintJustification` equality correctly.
646+
In other words, the diff will consider two justifications equal if the user says they are equal,
647+
and it expects the hash code to be consistent with equals.
648+
649+
If one `ScoreAnalysis` provides `MatchAnalysis` and the other doesn't, exception is thrown.
650+
Such `ScoreAnalysis` instances are mutually incompatible.
651+
652+
Parameters
653+
----------
654+
other : ScoreAnalysis
655+
656+
Returns
657+
-------
658+
ScoreExplanation
659+
The `ScoreAnalysis` corresponding to the diff.
660+
"""
661+
return ScoreAnalysis(self._delegate.diff(other._delegate))
662+
544663

545664
__all__ = ['ScoreExplanation',
546665
'ConstraintRef', 'ConstraintMatch', 'ConstraintMatchTotal',

0 commit comments

Comments
 (0)
This repository has been archived.