Skip to content

Commit 90f02f4

Browse files
authored
fix: match count in score analysis diff need not match the match list size (#1454)
1 parent 013aefd commit 90f02f4

File tree

3 files changed

+158
-14
lines changed

3 files changed

+158
-14
lines changed

core/src/main/java/ai/timefold/solver/core/api/score/analysis/ConstraintAnalysis.java

Lines changed: 33 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,19 @@
3131
* non-empty if constraint has matches.
3232
* This is a {@link List} to simplify access to individual elements,
3333
* but it contains no duplicates just like {@link HashSet} wouldn't.
34-
* @param matchCount -1 if analysis not available,
34+
* @param matchCount
35+
* <ul>
36+
* <li>For regular constraint analysis:
37+
* -1 if analysis not available,
3538
* 0 if constraint has no matches,
3639
* positive if constraint has matches.
40+
* Equal to the size of the {@link #matches} list.</li>
41+
* <li>For a {@link ScoreAnalysis#diff(ScoreAnalysis) diff of constraint analyses}:
42+
* positive if the constraint has more matches in the new analysis,
43+
* zero if the number of matches is the same in both,
44+
* negative otherwise.
45+
* Need not be equal to the size of the {@link #matches} list.</li>
46+
* </ul>
3747
*/
3848
public record ConstraintAnalysis<Score_ extends Score<Score_>>(@NonNull ConstraintRef constraintRef, @NonNull Score_ weight,
3949
@NonNull Score_ score, @Nullable List<MatchAnalysis<Score_>> matches, int matchCount) {
@@ -56,20 +66,22 @@ public ConstraintAnalysis(@NonNull ConstraintRef constraintRef, @NonNull Score_
5666
.formatted(DefaultConstraintMatchTotal.class.getSimpleName(),
5767
ConstraintMatchAwareIncrementalScoreCalculator.class.getSimpleName()));
5868
Objects.requireNonNull(score);
59-
if (matches != null && matchCount != matches.size()) {
60-
throw new IllegalArgumentException("The match count must be equal to the size of the matches list.");
61-
}
6269
}
6370

6471
@NonNull
6572
ConstraintAnalysis<Score_> negate() {
73+
// Only used to compute diff; use semantics for non-diff.
74+
// A negative match count is only allowed within these semantics when matches == null.
6675
if (matches == null) {
76+
// At this point, matchCount is already negative, as matches == null.
6777
return new ConstraintAnalysis<>(constraintRef, weight.negate(), score.negate(), null, matchCount);
6878
} else {
69-
var negatedMatchAnalyses = matches.stream()
79+
// Within these semantics, match count == list size.
80+
var negatedMatchAnalysesList = matches.stream()
7081
.map(MatchAnalysis::negate)
7182
.toList();
72-
return new ConstraintAnalysis<>(constraintRef, weight.negate(), score.negate(), negatedMatchAnalyses);
83+
return new ConstraintAnalysis<>(constraintRef, weight.negate(), score.negate(), negatedMatchAnalysesList,
84+
matchCount);
7385
}
7486
}
7587

@@ -99,19 +111,19 @@ ConstraintAnalysis<Score_> negate() {
99111
var constraintWeightDifference = constraintAnalysis.weight().subtract(otherConstraintAnalysis.weight());
100112
var scoreDifference = constraintAnalysis.score().subtract(otherConstraintAnalysis.score());
101113
if (matchAnalyses == null) {
102-
var leftCount = constraintAnalysis.matchCount();
103-
var rightCount = otherConstraintAnalysis.matchCount();
104-
if ((leftCount == -1 && rightCount != -1) || (leftCount != -1 && rightCount == -1)) {
114+
var leftHasMatchCount = hasMatchCount(constraintAnalysis);
115+
var rightHasMatchCount = hasMatchCount(otherConstraintAnalysis);
116+
if ((!leftHasMatchCount && rightHasMatchCount) || (leftHasMatchCount && !rightHasMatchCount)) {
105117
throw new IllegalStateException(
106118
"Impossible state: One of the score analyses (%s, %s) provided no match count for a constraint (%s)."
107119
.formatted(constraintAnalysis, otherConstraintAnalysis, constraintRef));
108120
}
109121
return new ConstraintAnalysis<>(constraintRef, constraintWeightDifference, scoreDifference, null,
110-
leftCount - rightCount);
122+
getMatchCount(constraintAnalysis, otherConstraintAnalysis));
111123
}
112124
var matchAnalysisMap = mapMatchesToJustifications(matchAnalyses);
113125
var otherMatchAnalysisMap = mapMatchesToJustifications(otherMatchAnalyses);
114-
var result = Stream.concat(matchAnalysisMap.keySet().stream(), otherMatchAnalysisMap.keySet().stream())
126+
var matchAnalysesList = Stream.concat(matchAnalysisMap.keySet().stream(), otherMatchAnalysisMap.keySet().stream())
115127
.distinct()
116128
.map(justification -> {
117129
var matchAnalysis = matchAnalysisMap.get(justification);
@@ -133,7 +145,16 @@ ConstraintAnalysis<Score_> negate() {
133145
}
134146
})
135147
.toList();
136-
return new ConstraintAnalysis<>(constraintRef, constraintWeightDifference, scoreDifference, result);
148+
return new ConstraintAnalysis<>(constraintRef, constraintWeightDifference, scoreDifference, matchAnalysesList,
149+
getMatchCount(constraintAnalysis, otherConstraintAnalysis));
150+
}
151+
152+
private static boolean hasMatchCount(ConstraintAnalysis<?> analysis) {
153+
return analysis.matchCount >= 0;
154+
}
155+
156+
private static int getMatchCount(ConstraintAnalysis<?> analysis, ConstraintAnalysis<?> otherAnalysis) {
157+
return analysis.matchCount() - otherAnalysis.matchCount();
137158
}
138159

139160
private static <Score_ extends Score<Score_>> Map<ConstraintJustification, MatchAnalysis<Score_>>

core/src/test/java/ai/timefold/solver/core/api/solver/SolutionManagerTest.java

Lines changed: 114 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -636,6 +636,120 @@ void recommendAssignmentWithUnassigned(SolutionManagerSource SolutionManagerSour
636636
});
637637
}
638638

639+
@ParameterizedTest
640+
@EnumSource(SolutionManagerSource.class)
641+
void recommendAssignmentWithUnassignedMatchCount(SolutionManagerSource SolutionManagerSource) {
642+
int valueSize = 3;
643+
var solution = TestdataAllowsUnassignedSolution.generateSolution(valueSize, 3);
644+
var uninitializedEntity = solution.getEntityList().get(2);
645+
uninitializedEntity.setValue(null);
646+
647+
// At this point, entity 0 and entity 2 are unassigned.
648+
// Entity 1 is assigned to value #1.
649+
// But only entity2 should be processed for recommendations.
650+
var solutionManager = SolutionManagerSource.createSolutionManager(SOLVER_FACTORY_UNASSIGNED);
651+
assertThat(solutionManager).isNotNull();
652+
var recommendationList =
653+
solutionManager.recommendAssignment(solution, uninitializedEntity, TestdataAllowsUnassignedEntity::getValue,
654+
ScoreAnalysisFetchPolicy.FETCH_MATCH_COUNT);
655+
656+
// Three values means there need to be four recommendations, one extra for unassigned.
657+
assertThat(recommendationList).hasSize(valueSize + 1);
658+
/*
659+
* The calculator penalizes how many entities have the same value as another entity.
660+
* Therefore the recommendation to assign value 0 and value 2 need to come first and in the order of the placer,
661+
* as it means two entities no longer share a value, improving the score.
662+
*/
663+
var recommendation1 = recommendationList.get(0);
664+
assertSoftly(softly -> {
665+
softly.assertThat(recommendation1.proposition()).isEqualTo(solution.getValueList().get(0));
666+
// Two entities no longer share null value; two less matches.
667+
softly.assertThat(recommendation1.scoreAnalysisDiff()
668+
.score()).isEqualTo(SimpleScore.of(2));
669+
softly.assertThat(recommendation1.scoreAnalysisDiff().constraintMap())
670+
.extractingFromEntries(e -> e.getValue().matchCount())
671+
.first()
672+
.isEqualTo(-2);
673+
});
674+
var recommendation2 = recommendationList.get(1);
675+
assertSoftly(softly -> {
676+
softly.assertThat(recommendation2.proposition()).isEqualTo(solution.getValueList().get(2));
677+
softly.assertThat(recommendation2.scoreAnalysisDiff()
678+
.score()).isEqualTo(SimpleScore.of(2));
679+
});
680+
// The other two recommendations need to come in order of the placer; so null, then value #1.
681+
var recommendation3 = recommendationList.get(2);
682+
assertSoftly(softly -> {
683+
softly.assertThat(recommendation3.proposition()).isEqualTo(null);
684+
softly.assertThat(recommendation3.scoreAnalysisDiff()
685+
.score()).isEqualTo(SimpleScore.ZERO);
686+
});
687+
var recommendation4 = recommendationList.get(3);
688+
assertSoftly(softly -> {
689+
softly.assertThat(recommendation4.proposition()).isEqualTo(solution.getValueList().get(1));
690+
softly.assertThat(recommendation4.scoreAnalysisDiff()
691+
.score()).isEqualTo(SimpleScore.ZERO);
692+
});
693+
// Ensure the original solution is in its original state.
694+
assertSoftly(softly -> {
695+
softly.assertThat(uninitializedEntity.getValue()).isNull();
696+
softly.assertThat(solution.getEntityList().get(0).getValue()).isNull();
697+
softly.assertThat(solution.getEntityList().get(1).getValue()).isEqualTo(solution.getValueList().get(1));
698+
softly.assertThat(solution.getEntityList().get(2).getValue()).isNull();
699+
softly.assertThat(solution.getScore()).isNull();
700+
});
701+
}
702+
703+
@ParameterizedTest
704+
@EnumSource(SolutionManagerSource.class)
705+
void recommendAssignmentWithUnassignedFetchAllVsFetchMatchCount(SolutionManagerSource SolutionManagerSource) {
706+
int valueSize = 3;
707+
var solution = TestdataAllowsUnassignedSolution.generateSolution(valueSize, 3);
708+
var uninitializedEntity = solution.getEntityList().get(2);
709+
uninitializedEntity.setValue(null);
710+
711+
// At this point, entity 0 and entity 2 are unassigned.
712+
// Entity 1 is assigned to value #1.
713+
// But only entity2 should be processed for recommendations.
714+
var solutionManager = SolutionManagerSource.createSolutionManager(SOLVER_FACTORY_UNASSIGNED);
715+
assertThat(solutionManager).isNotNull();
716+
var matchCountRecommendationList =
717+
solutionManager.recommendAssignment(solution, uninitializedEntity, TestdataAllowsUnassignedEntity::getValue,
718+
ScoreAnalysisFetchPolicy.FETCH_MATCH_COUNT);
719+
720+
var justificationsRecommendationList =
721+
solutionManager.recommendAssignment(solution, uninitializedEntity, TestdataAllowsUnassignedEntity::getValue,
722+
ScoreAnalysisFetchPolicy.FETCH_ALL);
723+
724+
// Three values means there need to be four recommendations, one extra for unassigned.
725+
assertThat(matchCountRecommendationList).hasSize(valueSize + 1);
726+
assertThat(justificationsRecommendationList).hasSize(valueSize + 1);
727+
/*
728+
* The calculator penalizes how many entities have the same value as another entity.
729+
* Therefore the recommendation to assign value 0 and value 2 need to come first and in the order of the placer,
730+
* as it means two entities no longer share a value, improving the score.
731+
*/
732+
var matchCountRecommendation1 = matchCountRecommendationList.get(0);
733+
var justificationsRecommendation1 = justificationsRecommendationList.get(0);
734+
735+
assertSoftly(softly -> {
736+
softly.assertThat(matchCountRecommendation1.proposition()).isEqualTo(solution.getValueList().get(0));
737+
softly.assertThat(justificationsRecommendation1.proposition()).isEqualTo(solution.getValueList().get(0));
738+
739+
softly.assertThat(matchCountRecommendation1.scoreAnalysisDiff()
740+
.score()).isEqualTo(SimpleScore.of(2)); // Two entities no longer share null value.
741+
softly.assertThat(justificationsRecommendation1.scoreAnalysisDiff()
742+
.score()).isEqualTo(SimpleScore.of(2)); // Two entities no longer share null value.
743+
744+
// The matchCount is expected to be the same in the case of both FETCH_ALL and FETCH_MATCH_COUNT
745+
int matchCountForFetchMatchCount = matchCountRecommendation1.scoreAnalysisDiff()
746+
.constraintMap().values().iterator().next().matchCount();
747+
int matchCountForFetchAll = justificationsRecommendation1.scoreAnalysisDiff()
748+
.constraintMap().values().iterator().next().matchCount();
749+
softly.assertThat(matchCountForFetchMatchCount).isEqualTo(matchCountForFetchAll);
750+
});
751+
}
752+
639753
@ParameterizedTest
640754
@EnumSource(SolutionManagerSource.class)
641755
void recommendAssignmentWithAllAssigned(SolutionManagerSource SolutionManagerSource) {

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

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -352,7 +352,7 @@ def constraint_match_total_map(self) -> dict[str, ConstraintMatchTotal]:
352352
e.getKey()[len(_user_package_prefix):]: ConstraintMatchTotal(
353353
constraint_ref=ConstraintRef(
354354
package_name=e.getValue().getConstraintRef().packageName()[len(_user_package_prefix):],
355-
constraint_name=e.getValue().getConstraintRef().constraintName()),
355+
constraint_name=e.getValue().getConstraintRef().constraintName()),
356356
constraint_match_count=e.getValue().getConstraintMatchCount(),
357357
constraint_match_set=_map_constraint_match_set(e.getValue().getConstraintMatchSet()),
358358
constraint_weight=to_python_score(e.getValue().getConstraintWeight()),
@@ -464,7 +464,16 @@ class ConstraintAnalysis(Generic[Score_]):
464464
Returns a diagnostic text
465465
that explains part of the score quality through the ConstraintAnalysis API.
466466
match_count : int
467-
Return the match count of the constraint.
467+
For regular constraint analysis:
468+
-1 if analysis not available,
469+
0 if constraint has no matches,
470+
positive if constraint has matches.
471+
Equal to the size of the matches list.</li>
472+
For a diff of constraint analyses:
473+
positive if the constraint has more matches in the new analysis,
474+
zero if the number of matches is the same in both,
475+
negative otherwise.
476+
Need not be equal to the size of the matches list.
468477
"""
469478
_delegate: '_JavaConstraintAnalysis[Score_]'
470479

0 commit comments

Comments
 (0)