Skip to content

Commit 4415f4d

Browse files
authored
feat: add constraint weight to ScoreAnalysis (#416)
Introduces constraint weight to ScoreAnalysis. Sorts constraint matches by constraint weight first.
1 parent 4e7d7b3 commit 4415f4d

File tree

13 files changed

+109
-54
lines changed

13 files changed

+109
-54
lines changed

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

Lines changed: 32 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@
88
import java.util.stream.Stream;
99

1010
import ai.timefold.solver.core.api.score.Score;
11+
import ai.timefold.solver.core.api.score.calculator.ConstraintMatchAwareIncrementalScoreCalculator;
1112
import ai.timefold.solver.core.api.score.constraint.ConstraintRef;
1213
import ai.timefold.solver.core.api.score.stream.ConstraintJustification;
1314
import ai.timefold.solver.core.api.solver.SolutionManager;
15+
import ai.timefold.solver.core.impl.score.constraint.DefaultConstraintMatchTotal;
1416
import ai.timefold.solver.core.impl.util.CollectionUtils;
1517

1618
/**
@@ -19,32 +21,49 @@
1921
*
2022
* @param <Score_>
2123
* @param constraintRef never null
24+
* @param weight never null
2225
* @param score never null
2326
* @param matches null if analysis not available;
2427
* empty if constraint has no matches, but still non-zero constraint weight;
2528
* non-empty if constraint has matches.
2629
* This is a {@link List} to simplify access to individual elements,
2730
* but it contains no duplicates just like {@link HashSet} wouldn't.
2831
*/
29-
public record ConstraintAnalysis<Score_ extends Score<Score_>>(ConstraintRef constraintRef, Score_ score,
30-
List<MatchAnalysis<Score_>> matches) {
32+
public record ConstraintAnalysis<Score_ extends Score<Score_>>(ConstraintRef constraintRef, Score_ weight,
33+
Score_ score, List<MatchAnalysis<Score_>> matches) {
3134

32-
static <Score_ extends Score<Score_>> ConstraintAnalysis<Score_> of(ConstraintRef constraintRef, Score_ score) {
33-
return new ConstraintAnalysis<>(constraintRef, score, null);
35+
static <Score_ extends Score<Score_>> ConstraintAnalysis<Score_> of(ConstraintRef constraintRef, Score_ constraintWeight,
36+
Score_ score) {
37+
return new ConstraintAnalysis<>(constraintRef, constraintWeight, score, null);
3438
}
3539

3640
public ConstraintAnalysis {
41+
Objects.requireNonNull(constraintRef);
42+
if (weight == null) {
43+
/*
44+
* Only possible in ConstraintMatchAwareIncrementalScoreCalculator and/or tests.
45+
* Easy doesn't support constraint analysis at all.
46+
* CS always provides constraint weights.
47+
*/
48+
throw new IllegalArgumentException("""
49+
The constraint weight must be non-null.
50+
Maybe use a non-deprecated %s constructor in your %s implementation?
51+
"""
52+
.stripTrailing()
53+
.formatted(DefaultConstraintMatchTotal.class.getSimpleName(),
54+
ConstraintMatchAwareIncrementalScoreCalculator.class.getSimpleName()));
55+
}
3756
Objects.requireNonNull(score);
3857
}
3958

4059
ConstraintAnalysis<Score_> negate() {
4160
if (matches == null) {
42-
return ConstraintAnalysis.of(constraintRef, score.negate());
61+
return ConstraintAnalysis.of(constraintRef, weight.negate(), score.negate());
4362
} else {
4463
var negatedMatchAnalyses = matches.stream()
4564
.map(MatchAnalysis::negate)
4665
.toList();
47-
return new ConstraintAnalysis<>(constraintRef, score.negate(), negatedMatchAnalyses);
66+
return new ConstraintAnalysis<>(constraintRef, weight.negate(), score.negate(), negatedMatchAnalyses);
4867
}
4968
}
5069

@@ -71,9 +90,10 @@ static <Score_ extends Score<Score_>> ConstraintAnalysis<Score_> diff(
7190
.formatted(constraintAnalysis, otherConstraintAnalysis, constraintRef));
7291
}
7392
// Compute the diff.
93+
var constraintWeightDifference = constraintAnalysis.weight().subtract(otherConstraintAnalysis.weight());
7494
var scoreDifference = constraintAnalysis.score().subtract(otherConstraintAnalysis.score());
7595
if (matchAnalyses == null) {
76-
return ConstraintAnalysis.of(constraintRef, scoreDifference);
96+
return ConstraintAnalysis.of(constraintRef, constraintWeightDifference, scoreDifference);
7797
}
7898
var matchAnalysisMap = mapMatchesToJustifications(matchAnalyses);
7999
var otherMatchAnalysisMap = mapMatchesToJustifications(otherMatchAnalyses);
@@ -99,7 +119,7 @@ static <Score_ extends Score<Score_>> ConstraintAnalysis<Score_> diff(
99119
}
100120
})
101121
.collect(Collectors.toList());
102-
return new ConstraintAnalysis<>(constraintRef, scoreDifference, result);
122+
return new ConstraintAnalysis<>(constraintRef, constraintWeightDifference, scoreDifference, result);
103123
}
104124

105125
private static <Score_ extends Score<Score_>> Map<ConstraintJustification, MatchAnalysis<Score_>>
@@ -121,9 +141,11 @@ static <Score_ extends Score<Score_>> ConstraintAnalysis<Score_> diff(
121141
@Override
122142
public String toString() {
123143
if (matches == null) {
124-
return "(" + score + ", no match analysis)";
144+
return "(%s at %s, no matches)"
145+
.formatted(score, weight);
125146
} else {
126-
return "(" + score + ", " + matches.size() + " matches)";
147+
return "(%s at %s, %s matches)"
148+
.formatted(score, weight, matches.size());
127149
}
128150
}
129151
}

core/core-impl/src/main/java/ai/timefold/solver/core/api/score/analysis/ScoreAnalysis.java

Lines changed: 19 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package ai.timefold.solver.core.api.score.analysis;
22

33
import java.util.Collections;
4+
import java.util.Comparator;
5+
import java.util.LinkedHashMap;
46
import java.util.Map;
57
import java.util.Objects;
68
import java.util.TreeMap;
@@ -38,8 +40,13 @@
3840
*
3941
* @param score never null
4042
* @param constraintMap never null;
41-
* constraints will be present even if they have no matches, unless their weight is zero;
43+
* for each constraint identified by its {@link Constraint#getConstraintRef()},
44+
* the {@link ConstraintAnalysis} that describes the impact of that constraint on the overall score.
45+
* Constraints are present even if they have no matches, unless their weight is zero;
4246
* zero-weight constraints are not present.
47+
* Entries in the map have a stable iteration order; items are ordered first by {@link ConstraintAnalysis#weight()},
48+
* then by {@link ConstraintAnalysis#constraintRef()}.
49+
*
4350
* @param <Score_>
4451
*/
4552
public record ScoreAnalysis<Score_ extends Score<Score_>>(Score_ score,
@@ -52,17 +59,17 @@ public record ScoreAnalysis<Score_ extends Score<Score_>>(Score_ score,
5259
throw new IllegalArgumentException("The constraintMap must not be empty.");
5360
}
5461
// Ensure consistent order and no external interference.
55-
constraintMap = Collections.unmodifiableMap(new TreeMap<>(constraintMap));
56-
}
57-
58-
/**
59-
* For each constraint identified by its {@link Constraint#getConstraintRef()} id},
60-
* the {@link ConstraintAnalysis} that describes the impact of that constraint on the overall score.
61-
*
62-
* @return never null, unmodifiable
63-
*/
64-
public Map<ConstraintRef, ConstraintAnalysis<Score_>> constraintMap() {
65-
return constraintMap;
62+
var comparator = Comparator.<ConstraintAnalysis<Score_>, Score_> comparing(ConstraintAnalysis::weight)
63+
.reversed()
64+
.thenComparing(ConstraintAnalysis::constraintRef);
65+
constraintMap = Collections.unmodifiableMap(constraintMap.values()
66+
.stream()
67+
.sorted(comparator)
68+
.collect(Collectors.toMap(
69+
ConstraintAnalysis::constraintRef,
70+
Function.identity(),
71+
(constraintAnalysis, otherConstraintAnalysis) -> constraintAnalysis,
72+
LinkedHashMap::new)));
6673
}
6774

6875
/**
@@ -127,8 +134,4 @@ public ScoreAnalysis<Score_> diff(ScoreAnalysis<Score_> other) {
127134
return new ScoreAnalysis<>(score.subtract(other.score()), result);
128135
}
129136

130-
@Override
131-
public String toString() {
132-
return "(" + score + ", " + constraintMap + ")";
133-
}
134137
}

core/core-impl/src/main/java/ai/timefold/solver/core/impl/score/constraint/DefaultConstraintMatchTotal.java

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -38,6 +38,11 @@ public DefaultConstraintMatchTotal(String constraintPackage, String constraintNa
3838
this(ConstraintRef.of(constraintPackage, constraintName));
3939
}
4040

41+
/**
42+
*
43+
* @deprecated Prefer {@link #DefaultConstraintMatchTotal(ConstraintRef, Score_)}.
44+
*/
45+
@Deprecated(forRemoval = true, since = "1.5.0")
4146
public DefaultConstraintMatchTotal(ConstraintRef constraintRef) {
4247
this.constraintRef = requireNonNull(constraintRef);
4348
this.constraintWeight = null;

core/core-impl/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -62,10 +62,12 @@ static <Score_ extends Score<Score_>> ConstraintAnalysis<Score_> getConstraintAn
6262
return new MatchAnalysis<>(constraintMatchTotal.getConstraintRef(), score, entry.getKey());
6363
})
6464
.toList();
65-
return new ConstraintAnalysis<>(constraintMatchTotal.getConstraintRef(), constraintMatchTotal.getScore(),
65+
return new ConstraintAnalysis<>(constraintMatchTotal.getConstraintRef(), constraintMatchTotal.getConstraintWeight(),
66+
constraintMatchTotal.getScore(),
6667
matchAnalyses);
6768
} else {
68-
return new ConstraintAnalysis<>(constraintMatchTotal.getConstraintRef(), constraintMatchTotal.getScore(), null);
69+
return new ConstraintAnalysis<>(constraintMatchTotal.getConstraintRef(), constraintMatchTotal.getConstraintWeight(),
70+
constraintMatchTotal.getScore(), null);
6971
}
7072
}
7173

core/core-impl/src/test/java/ai/timefold/solver/core/impl/testdata/domain/TestdataIncrementalScoreCalculator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ public class TestdataIncrementalScoreCalculator
2828
public void resetWorkingSolution(TestdataSolution workingSolution) {
2929
score = 0;
3030
constraintMatchTotal = new DefaultConstraintMatchTotal<>(
31-
ConstraintRef.of("ai.timefold.solver.core.impl.testdata.domain", "testConstraint"));
31+
ConstraintRef.of("ai.timefold.solver.core.impl.testdata.domain", "testConstraint"), SimpleScore.ONE);
3232
indictmentMap = new HashMap<>();
3333
for (TestdataEntity left : workingSolution.getEntityList()) {
3434
TestdataValue value = left.getValue();

core/core-impl/src/test/java/ai/timefold/solver/core/impl/testdata/domain/chained/shadow/TestdataShadowingChainedIncrementalScoreCalculator.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ public class TestdataShadowingChainedIncrementalScoreCalculator
2828
public void resetWorkingSolution(TestdataShadowingChainedSolution workingSolution) {
2929
score = 0;
3030
constraintMatchTotal = new DefaultConstraintMatchTotal<>(
31-
ConstraintRef.of("ai.timefold.solver.core.impl.testdata.domain.chained.shadow", "testConstraint"));
31+
ConstraintRef.of("ai.timefold.solver.core.impl.testdata.domain.chained.shadow", "testConstraint"),
32+
SimpleScore.ONE);
3233
indictmentMap = new HashMap<>();
3334
for (TestdataShadowingChainedEntity left : workingSolution.getChainedEntityList()) {
3435
String code = left.getCode();

core/core-impl/src/test/java/ai/timefold/solver/core/impl/testdata/domain/list/shadow_history/TestdataListWithShadowHistoryIncrementalScoreCalculator.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,8 @@ public class TestdataListWithShadowHistoryIncrementalScoreCalculator
2828
public void resetWorkingSolution(TestdataListSolutionWithShadowHistory workingSolution) {
2929
score = 0;
3030
constraintMatchTotal = new DefaultConstraintMatchTotal<>(
31-
ConstraintRef.of("ai.timefold.solver.core.impl.testdata.domain.chained.shadow", "testConstraint"));
31+
ConstraintRef.of("ai.timefold.solver.core.impl.testdata.domain.chained.shadow", "testConstraint"),
32+
SimpleScore.ONE);
3233
indictmentMap = new HashMap<>();
3334
for (TestdataListEntityWithShadowHistory left : workingSolution.getEntityList()) {
3435
String code = left.getCode();

core/core-impl/src/test/java/ai/timefold/solver/core/impl/testdata/domain/nullable/TestdataNullableIncrementalScoreCalculator.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ public final class TestdataNullableIncrementalScoreCalculator
2929
public void resetWorkingSolution(TestdataNullableSolution workingSolution) {
3030
score = 0;
3131
constraintMatchTotal = new DefaultConstraintMatchTotal<>(
32-
ConstraintRef.of("ai.timefold.solver.core.impl.testdata.domain.shadow", "testConstraint"));
32+
ConstraintRef.of("ai.timefold.solver.core.impl.testdata.domain.shadow", "testConstraint"), SimpleScore.ONE);
3333
indictmentMap = new HashMap<>();
3434
for (TestdataNullableEntity left : workingSolution.getEntityList()) {
3535
TestdataValue value = left.getValue();

core/core-impl/src/test/java/ai/timefold/solver/core/impl/testdata/domain/shadow/TestdataShadowedIncrementalScoreCalculator.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,8 @@ public class TestdataShadowedIncrementalScoreCalculator
2929
public void resetWorkingSolution(TestdataShadowedSolution workingSolution) {
3030
score = 0;
3131
constraintMatchTotal = new DefaultConstraintMatchTotal<>(
32-
ConstraintRef.of("ai.timefold.solver.core.impl.testdata.domain.shadow", "testConstraint"));
32+
ConstraintRef.of("ai.timefold.solver.core.impl.testdata.domain.shadow", "testConstraint"),
33+
SimpleScore.ONE);
3334
indictmentMap = new HashMap<>();
3435
for (TestdataShadowedEntity left : workingSolution.getEntityList()) {
3536
TestdataValue value = left.getValue();

examples/src/main/java/ai/timefold/solver/examples/machinereassignment/optional/score/MachineReassignmentIncrementalScoreCalculator.java

Lines changed: 17 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -440,23 +440,26 @@ public void resetWorkingSolution(MachineReassignment workingSolution, boolean co
440440
@Override
441441
public Collection<ConstraintMatchTotal<HardSoftLongScore>> getConstraintMatchTotals() {
442442
DefaultConstraintMatchTotal<HardSoftLongScore> maximumCapacityMatchTotal =
443-
new DefaultConstraintMatchTotal<>(ConstraintRef.of(CONSTRAINT_PACKAGE, MrConstraints.MAXIMUM_CAPACITY));
443+
getConstraintMatchTotal(MrConstraints.MAXIMUM_CAPACITY, HardSoftLongScore.ONE_HARD);
444444
DefaultConstraintMatchTotal<HardSoftLongScore> serviceConflictMatchTotal =
445-
new DefaultConstraintMatchTotal<>(ConstraintRef.of(CONSTRAINT_PACKAGE, MrConstraints.SERVICE_CONFLICT));
445+
getConstraintMatchTotal(MrConstraints.SERVICE_CONFLICT, HardSoftLongScore.ONE_HARD);
446446
DefaultConstraintMatchTotal<HardSoftLongScore> serviceLocationSpreadMatchTotal =
447-
new DefaultConstraintMatchTotal<>(ConstraintRef.of(CONSTRAINT_PACKAGE, MrConstraints.SERVICE_LOCATION_SPREAD));
447+
getConstraintMatchTotal(MrConstraints.SERVICE_LOCATION_SPREAD, HardSoftLongScore.ONE_HARD);
448448
DefaultConstraintMatchTotal<HardSoftLongScore> serviceDependencyMatchTotal =
449-
new DefaultConstraintMatchTotal<>(ConstraintRef.of(CONSTRAINT_PACKAGE, MrConstraints.SERVICE_DEPENDENCY));
449+
getConstraintMatchTotal(MrConstraints.SERVICE_DEPENDENCY, HardSoftLongScore.ONE_HARD);
450450
DefaultConstraintMatchTotal<HardSoftLongScore> loadCostMatchTotal =
451-
new DefaultConstraintMatchTotal<>(ConstraintRef.of(CONSTRAINT_PACKAGE, MrConstraints.LOAD_COST));
451+
getConstraintMatchTotal(MrConstraints.LOAD_COST, HardSoftLongScore.ONE_SOFT);
452452
DefaultConstraintMatchTotal<HardSoftLongScore> balanceCostMatchTotal =
453-
new DefaultConstraintMatchTotal<>(ConstraintRef.of(CONSTRAINT_PACKAGE, MrConstraints.BALANCE_COST));
453+
getConstraintMatchTotal(MrConstraints.BALANCE_COST, HardSoftLongScore.ONE_SOFT);
454454
DefaultConstraintMatchTotal<HardSoftLongScore> processMoveCostMatchTotal =
455-
new DefaultConstraintMatchTotal<>(ConstraintRef.of(CONSTRAINT_PACKAGE, MrConstraints.PROCESS_MOVE_COST));
455+
getConstraintMatchTotal(MrConstraints.PROCESS_MOVE_COST,
456+
HardSoftLongScore.ofSoft(globalPenaltyInfo.getProcessMoveCostWeight()));
456457
DefaultConstraintMatchTotal<HardSoftLongScore> serviceMoveCostMatchTotal =
457-
new DefaultConstraintMatchTotal<>(ConstraintRef.of(CONSTRAINT_PACKAGE, MrConstraints.SERVICE_MOVE_COST));
458+
getConstraintMatchTotal(MrConstraints.SERVICE_MOVE_COST,
459+
HardSoftLongScore.ofSoft(globalPenaltyInfo.getServiceMoveCostWeight()));
458460
DefaultConstraintMatchTotal<HardSoftLongScore> machineMoveCostMatchTotal =
459-
new DefaultConstraintMatchTotal<>(ConstraintRef.of(CONSTRAINT_PACKAGE, MrConstraints.MACHINE_MOVE_COST));
461+
getConstraintMatchTotal(MrConstraints.MACHINE_MOVE_COST,
462+
HardSoftLongScore.ofSoft(globalPenaltyInfo.getMachineMoveCostWeight()));
460463

461464
for (MrServiceScorePart serviceScorePart : serviceScorePartMap.values()) {
462465
MrService service = serviceScorePart.service;
@@ -540,6 +543,11 @@ public Collection<ConstraintMatchTotal<HardSoftLongScore>> getConstraintMatchTot
540543
return constraintMatchTotalList;
541544
}
542545

546+
private static DefaultConstraintMatchTotal<HardSoftLongScore> getConstraintMatchTotal(String constraintName,
547+
HardSoftLongScore constraintWeight) {
548+
return new DefaultConstraintMatchTotal<>(ConstraintRef.of(CONSTRAINT_PACKAGE, constraintName), constraintWeight);
549+
}
550+
543551
@Override
544552
public Map<Object, Indictment<HardSoftLongScore>> getIndictmentMap() {
545553
return null; // Calculate it non-incrementally from getConstraintMatchTotals()

0 commit comments

Comments
 (0)