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

Commit c53c04c

Browse files
committed
feat: sync ScoreAnalysis API
1 parent edc23d0 commit c53c04c

File tree

4 files changed

+142
-14
lines changed

4 files changed

+142
-14
lines changed

pyproject.toml

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ requires = [
33
"setuptools>=69.1.1",
44
"stubgenj>=0.2.5",
55
"JPype1>=1.5.0",
6-
"wheel"
6+
"wheel",
7+
"multipledispatch>=1.0.0"
78
]
89
build-backend = "setuptools.build_meta"

tests/test_solution_manager.py

Lines changed: 52 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

@@ -128,14 +141,14 @@ def assert_score_analysis(problem: Solution, score_analysis: ScoreAnalysis):
128141

129142

130143
def assert_score_analysis_summary(score_analysis: ScoreAnalysis):
131-
summary = score_analysis.summary
144+
summary = score_analysis.summarize
132145
assert "Explanation of score (3):" in summary
133146
assert "Constraint matches:" in summary
134147
assert "3: constraint (Maximize Value) has 3 matches:" in summary
135148
assert "1: justified with" in summary
136149

137150
match = score_analysis.constraint_analyses[0]
138-
match_summary = match.summary
151+
match_summary = match.summarize
139152
assert "Explanation of score (3):" in match_summary
140153
assert "Constraint matches:" in match_summary
141154
assert "3: constraint (Maximize Value) has 3 matches:" in match_summary
@@ -166,3 +179,40 @@ def test_solver_manager_score_manager():
166179

167180
def test_solver_factory_score_manager():
168181
assert_solution_manager(SolutionManager.create(SolverFactory.create(solver_config)))
182+
183+
184+
def test_score_manager_solution_initialization():
185+
solution_manager = SolutionManager.create(SolverFactory.create(solver_config))
186+
problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1)], [1, 2, 3])
187+
score_analysis = solution_manager.analyze(problem)
188+
assert score_analysis.is_solution_initialized
189+
190+
second_problem: Solution = Solution([Entity('A', None), Entity('B', None), Entity('C', None)], [1, 2, 3])
191+
second_score_analysis = solution_manager.analyze(second_problem)
192+
assert not second_score_analysis.is_solution_initialized
193+
194+
195+
def test_score_manager_diff():
196+
solution_manager = SolutionManager.create(SolverFactory.create(solver_config))
197+
problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1)], [1, 2, 3])
198+
score_analysis = solution_manager.analyze(problem)
199+
second_problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1), Entity('D', 1)], [1, 2, 3])
200+
second_score_analysis = solution_manager.analyze(second_problem)
201+
diff = score_analysis.diff(second_score_analysis)
202+
assert diff.score.score == -1
203+
204+
constraint_analyses = score_analysis.constraint_analyses
205+
assert len(constraint_analyses) == 1
206+
207+
def test_score_manager_constraint_analysis_map():
208+
solution_manager = SolutionManager.create(SolverFactory.create(solver_config))
209+
problem: Solution = Solution([Entity('A', 1), Entity('B', 1), Entity('C', 1)], [1, 2, 3])
210+
score_analysis = solution_manager.analyze(problem)
211+
constraints = score_analysis.constraint_analyses
212+
assert len(constraints) == 1
213+
214+
constraint_analysis = score_analysis.constraint_analysis('package', 'Maximize Value')
215+
assert constraint_analysis.constraint_name == 'Maximize Value'
216+
217+
constraint_analysis = score_analysis.constraint_analysis(ConstraintRef('package', 'Maximize Value'))
218+
assert constraint_analysis.constraint_name == 'Maximize Value'

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

Lines changed: 86 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
from .._jpype_type_conversions import to_python_score
33
from _jpyinterpreter import unwrap_python_like_object, add_java_interface
44
from dataclasses import dataclass
5+
from multipledispatch import dispatch
56

67
from typing import TypeVar, Generic, Union, TYPE_CHECKING, Any, cast, Optional, Type
78

@@ -456,7 +457,7 @@ def __init__(self, delegate: '_JavaConstraintAnalysis[Score_]'):
456457
delegate.constraintRef()
457458

458459
def __str__(self):
459-
return self.summary
460+
return self.summarize
460461

461462
@property
462463
def constraint_ref(self) -> ConstraintRef:
@@ -485,7 +486,7 @@ def score(self) -> Score_:
485486
return to_python_score(self._delegate.score())
486487

487488
@property
488-
def summary(self) -> str:
489+
def summarize(self) -> str:
489490
return self._delegate.summarize()
490491

491492
class ScoreAnalysis:
@@ -518,15 +519,19 @@ class ScoreAnalysis:
518519
constraint_analyses : list[ConstraintAnalysis]
519520
Individual ConstraintAnalysis instances that make up this ScoreAnalysis.
520521
521-
summary : str
522-
Returns a diagnostic text
523-
that explains the solution through the `ConstraintMatch` API
524-
to identify which constraints cause that score quality.
522+
summarize : str
523+
Returns a diagnostic text that explains the solution through the `ConstraintAnalysis` API to identify which
524+
Constraints cause that score quality.
525+
The string is built fresh every time the method is called.
525526
526527
In case of an infeasible solution, this can help diagnose the cause of that.
528+
527529
Do not parse the return value, its format may change without warning.
528-
Instead, to provide this information in a UI or a service,
529-
use `constraint_analyses` and convert those into a domain-specific API.
530+
Instead, provide this information in a UI or a service,
531+
use `constraintAnalyses()`
532+
and convert those into a domain-specific API.
533+
534+
is_solution_initialized : bool
530535
531536
Notes
532537
-----
@@ -539,7 +544,7 @@ def __init__(self, delegate: '_JavaScoreAnalysis'):
539544
self._delegate = delegate
540545

541546
def __str__(self):
542-
return self.summary
547+
return self.summarize
543548

544549
@property
545550
def score(self) -> 'Score':
@@ -562,10 +567,81 @@ def constraint_analyses(self) -> list[ConstraintAnalysis]:
562567
list['_JavaConstraintAnalysis[Score]'], self._delegate.constraintAnalyses())
563568
]
564569

570+
@dispatch(str, str)
571+
def constraint_analysis(self, constraint_package: str, constraint_name: str) -> ConstraintAnalysis:
572+
"""
573+
Performs a lookup on `constraint_map`.
574+
575+
Parameters
576+
----------
577+
constraint_package : str
578+
constraint_name : str
579+
580+
Returns
581+
-------
582+
ConstraintAnalysis
583+
None if no constraint matches of such constraint are present
584+
"""
585+
return ConstraintAnalysis(self._delegate.getConstraintAnalysis(constraint_package, constraint_name))
586+
587+
@dispatch(ConstraintRef)
588+
def constraint_analysis(self, constraint_ref: ConstraintRef) -> ConstraintAnalysis:
589+
"""
590+
Performs a lookup on `constraint_map`.
591+
592+
Parameters
593+
----------
594+
constraint_ref : ConstraintRef
595+
596+
Returns
597+
-------
598+
ConstraintAnalysis
599+
None if no constraint matches of such constraint are present
600+
"""
601+
return self.constraint_analysis(constraint_ref.package_name, constraint_ref.constraint_name)
602+
565603
@property
566-
def summary(self) -> str:
604+
def summarize(self) -> str:
567605
return self._delegate.summarize()
568606

607+
@property
608+
def is_solution_initialized(self) -> bool:
609+
return self._delegate.isSolutionInitialized()
610+
611+
612+
def diff(self, other: 'ScoreAnalysis') -> 'ScoreAnalysis':
613+
"""
614+
Compare this `ScoreAnalysis to another `ScoreAnalysis`
615+
and retrieve the difference between them.
616+
The comparison is in the direction of `this - other`.
617+
618+
Example: if `this` has a score of 100 and `other` has a score of 90,
619+
the returned score will be 10.
620+
If this and other were inverted, the score would have been -10.
621+
The same applies to all other properties of `ScoreAnalysis`.
622+
623+
In order to properly diff `MatchAnalysis` against each other,
624+
we rely on the user implementing `ConstraintJustification` equality correctly.
625+
In other words, the diff will consider two justifications equal if the user says they are equal,
626+
and it expects the hash code to be consistent with equals.
627+
628+
If one `ScoreAnalysis` provides `MatchAnalysis` and the other doesn't, exception is thrown.
629+
Such `ScoreAnalysis` instances are mutually incompatible.
630+
631+
Parameters
632+
----------
633+
other : ScoreAnalysis
634+
635+
Returns
636+
-------
637+
ScoreExplanation
638+
The `ScoreAnalysis` corresponding to the diff.
639+
"""
640+
return ScoreAnalysis(self._delegate.diff(other._delegate))
641+
642+
643+
644+
569645

570646
__all__ = ['ScoreExplanation',
571647
'ConstraintRef', 'ConstraintMatch', 'ConstraintMatchTotal',

tox.ini

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,8 +13,9 @@ deps =
1313
pytest-cov>=4.1.0
1414
coverage>=7.4.3
1515
JPype1>=1.5.0
16+
multipledispatch>=1.0.0
1617
commands =
17-
pytest --import-mode=importlib {posargs} tests
18+
pytest -s --import-mode=importlib {posargs} tests
1819

1920
[coverage:paths]
2021
source =

0 commit comments

Comments
 (0)