Skip to content

Commit

Permalink
feat: enable score analysis without justifications (#1185)
Browse files Browse the repository at this point in the history
Introduces an option to disable constraint match justifications,
while still keeping constraint matching enabled.
In some cases, the dataset is so large that justifications are
pointless,
and impractical to send over the wire as well.
In this case, score analysis only provides constraint match count, and
not the full match analysis.
  • Loading branch information
triceo authored Nov 7, 2024
1 parent d0602fc commit 06d2c82
Show file tree
Hide file tree
Showing 116 changed files with 1,047 additions and 582 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -128,7 +128,8 @@ public SubSingleBenchmarkRunner<Solution_> call() {
subSingleBenchmarkResult.setMoveEvaluationCount(solverScope.getMoveEvaluationCount());

SolutionManager<Solution_, ?> solutionManager = SolutionManager.create(solverFactory);
boolean isConstraintMatchEnabled = solver.getSolverScope().getScoreDirector().isConstraintMatchEnabled();
boolean isConstraintMatchEnabled = solver.getSolverScope().getScoreDirector().getConstraintMatchPolicy()
.isEnabled();
if (isConstraintMatchEnabled) { // Easy calculator fails otherwise.
ScoreExplanation<Solution_, ?> scoreExplanation =
solutionManager.explain(solution, SolutionUpdatePolicy.NO_UPDATE);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -100,6 +100,7 @@ public interface ScoreExplanation<Solution_, Score_ extends Score<Score_>> {
* Explains the {@link Score} of {@link #getScore()} for all constraints
* justified with a given {@link ConstraintJustification} type.
* Otherwise, as defined by {@link #getJustificationList()}.
* May be empty, if the score explanation ran with justification support disabled.
*
* @return all constraint matches associated with the given justification class
* @see #getIndictmentMap()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,15 +31,16 @@
* non-empty if constraint has matches.
* This is a {@link List} to simplify access to individual elements,
* but it contains no duplicates just like {@link HashSet} wouldn't.
* @param matchCount -1 if analysis not available,
* 0 if constraint has no matches,
* positive if constraint has matches.
*/
public record ConstraintAnalysis<Score_ extends Score<Score_>>(@NonNull ConstraintRef constraintRef, @NonNull Score_ weight,
@NonNull Score_ score, @Nullable List<MatchAnalysis<Score_>> matches) {
@NonNull Score_ score, @Nullable List<MatchAnalysis<Score_>> matches, int matchCount) {

static <Score_ extends Score<Score_>> @NonNull ConstraintAnalysis<Score_> of(
@NonNull ConstraintRef constraintRef,
@NonNull Score_ constraintWeight,
@NonNull Score_ score) {
return new ConstraintAnalysis<>(constraintRef, constraintWeight, score, null);
public ConstraintAnalysis(@NonNull ConstraintRef constraintRef, @NonNull Score_ weight, @NonNull Score_ score,
@Nullable List<MatchAnalysis<Score_>> matches) {
this(constraintRef, weight, score, matches, matches == null ? -1 : matches.size());
}

public ConstraintAnalysis {
Expand All @@ -55,26 +56,14 @@ public record ConstraintAnalysis<Score_ extends Score<Score_>>(@NonNull Constrai
.formatted(DefaultConstraintMatchTotal.class.getSimpleName(),
ConstraintMatchAwareIncrementalScoreCalculator.class.getSimpleName()));
Objects.requireNonNull(score);
}

/**
* Return the match count of the constraint.
*
* @throws IllegalStateException if the {@link ConstraintAnalysis#matches()} is null
*/
public int matchCount() {
if (matches == null) {
throw new IllegalArgumentException("""
The constraint matches must be non-null.
Maybe use ScoreAnalysisFetchPolicy.FETCH_ALL to request the score analysis
""");
if (matches != null && matchCount != matches.size()) {
throw new IllegalArgumentException("The match count must be equal to the size of the matches list.");
}
return matches.size();
}

ConstraintAnalysis<Score_> negate() {
if (matches == null) {
return ConstraintAnalysis.of(constraintRef, weight.negate(), score.negate());
return new ConstraintAnalysis<>(constraintRef, weight.negate(), score.negate(), null, matchCount);
} else {
var negatedMatchAnalyses = matches.stream()
.map(MatchAnalysis::negate)
Expand Down Expand Up @@ -102,14 +91,22 @@ static <Score_ extends Score<Score_>> ConstraintAnalysis<Score_> diff(
var otherMatchAnalyses = otherConstraintAnalysis.matches();
if ((matchAnalyses == null && otherMatchAnalyses != null) || (matchAnalyses != null && otherMatchAnalyses == null)) {
throw new IllegalStateException(
"Impossible state: Only one of the score analyses (%s, %s) provided match analyses for a constraint (%s)."
"Impossible state: One of the score analyses (%s, %s) provided no match analysis for a constraint (%s)."
.formatted(constraintAnalysis, otherConstraintAnalysis, constraintRef));
}
// Compute the diff.
var constraintWeightDifference = constraintAnalysis.weight().subtract(otherConstraintAnalysis.weight());
var scoreDifference = constraintAnalysis.score().subtract(otherConstraintAnalysis.score());
if (matchAnalyses == null) {
return ConstraintAnalysis.of(constraintRef, constraintWeightDifference, scoreDifference);
var leftCount = constraintAnalysis.matchCount();
var rightCount = otherConstraintAnalysis.matchCount();
if ((leftCount == -1 && rightCount != -1) || (leftCount != -1 && rightCount == -1)) {
throw new IllegalStateException(
"Impossible state: One of the score analyses (%s, %s) provided no match count for a constraint (%s)."
.formatted(constraintAnalysis, otherConstraintAnalysis, constraintRef));
}
return new ConstraintAnalysis<>(constraintRef, constraintWeightDifference, scoreDifference, null,
leftCount - rightCount);
}
var matchAnalysisMap = mapMatchesToJustifications(matchAnalyses);
var otherMatchAnalysisMap = mapMatchesToJustifications(otherMatchAnalyses);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,10 @@ public record MatchAnalysis<Score_ extends Score<Score_>>(@NonNull ConstraintRef
public MatchAnalysis {
Objects.requireNonNull(constraintRef);
Objects.requireNonNull(score);
Objects.requireNonNull(justification, """
Received a null justification.
// Null justification is impossible;
// if the fetch policy doesn't requre match analysis, the code shouldn't even get here.
Objects.requireNonNull(justification, () -> """
Impossible state: Received a null justification.
Maybe check your %s's justifyWith() implementation for that constraint?"""
.formatted(ConstraintProvider.class));
}
Expand All @@ -42,8 +44,9 @@ public int compareTo(MatchAnalysis<Score_> other) {
if (scoreComparison != 0) {
return scoreComparison;
} else {
if (this.justification instanceof Comparable && other.justification instanceof Comparable) {
return ((Comparable) this.justification).compareTo(other.justification);
if (this.justification instanceof Comparable comparableJustification
&& other.justification instanceof Comparable otherComparableJustification) {
return comparableJustification.compareTo(otherComparableJustification);
} else {
return 0;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@
import ai.timefold.solver.core.api.solver.SolutionManager;

import org.jspecify.annotations.NonNull;
import org.jspecify.annotations.Nullable;

/**
* Retrievable from {@link ConstraintMatchTotal#getConstraintMatchSet()}
Expand Down Expand Up @@ -85,10 +86,16 @@ public ConstraintMatch(String constraintId, String constraintPackage, String con
this(new ConstraintRef(constraintPackage, constraintName, constraintId), justification, indictedObjectList, score);
}

public ConstraintMatch(@NonNull ConstraintRef constraintRef, @NonNull ConstraintJustification justification,
/**
* @param constraintRef unique identifier of the constraint
* @param justification only null if justifications are disabled
* @param indictedObjectList never null, empty if justifications are disabled
* @param score penalty or reward associated with the constraint match
*/
public ConstraintMatch(@NonNull ConstraintRef constraintRef, @Nullable ConstraintJustification justification,
@NonNull Collection<Object> indictedObjectList, @NonNull Score_ score) {
this.constraintRef = requireNonNull(constraintRef);
this.justification = requireNonNull(justification);
this.justification = justification;
this.indictedObjectList =
requireNonNull(indictedObjectList) instanceof List<Object> list ? list : List.copyOf(indictedObjectList);
this.score = requireNonNull(score);
Expand Down Expand Up @@ -160,9 +167,10 @@ public List<Object> getJustificationList() {
* (eg. [A, B] for a bi stream), unless a custom justification mapping was provided,
* in which case it returns the return value of that function.</li>
* <li>For incremental score calculation, it returns what the calculator is implemented to return.</li>
* <li>It may return null, if justification support was disabled altogether.</li>
* </ul>
*/
public <Justification_ extends ConstraintJustification> @NonNull Justification_ getJustification() {
public <Justification_ extends ConstraintJustification> @Nullable Justification_ getJustification() {
return (Justification_) justification;
}

Expand All @@ -175,6 +183,7 @@ public List<Object> getJustificationList() {
* (eg. [A, B] for a bi stream), unless a custom indictment mapping was provided,
* in which case it returns the return value of that function.</li>
* <li>For incremental score calculation, it returns what the calculator is implemented to return.</li>
* <li>It may return an empty list, if justification support was disabled altogether.</li>
* </ul>
*
* @return may be empty or contain null
Expand All @@ -201,6 +210,10 @@ public int compareTo(ConstraintMatch<Score_> other) {
return constraintRef.compareTo(other.constraintRef);
} else if (!score.equals(other.score)) {
return score.compareTo(other.score);
} else if (justification == null) {
return other.justification == null ? 0 : -1;
} else if (other.justification == null) {
return 1;
} else if (justification instanceof Comparable comparable) {
return comparable.compareTo(other.justification);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@
* All classes used as constraint justifications must implement this interface.
*
* <p>
* Implementing classes ("implementations") may decide to implement {@link Comparable}
* Implementations may decide to implement {@link Comparable}
* to preserve order of instances when displayed in user interfaces, logs etc.
* This is entirely optional.
*
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

/**
* Determines the depth of {@link SolutionManager#analyze(Object) score analysis}.
* If unsure, pick {@link #FETCH_ALL}.
* If unsure, pick {@link #FETCH_MATCH_COUNT}.
*
*/
public enum ScoreAnalysisFetchPolicy {
Expand All @@ -17,9 +17,18 @@ public enum ScoreAnalysisFetchPolicy {
FETCH_ALL,
/**
* {@link ConstraintAnalysis} included in {@link ScoreAnalysis}
* does not include {@link ConstraintAnalysis#matches() match analysis}.
* provides neither {@link ConstraintAnalysis#matches() match analysis}
* nor {@link ConstraintAnalysis#matchCount() match count}.
* This is useful for performance reasons when the match analysis is not needed.
*/
FETCH_SHALLOW
FETCH_SHALLOW,
/**
* {@link ConstraintAnalysis} included in {@link ScoreAnalysis}
* does not provide {@link ConstraintAnalysis#matches() match analysis},
* but does provide {@link ConstraintAnalysis#matchCount() match count}.
* This is useful when there are too many matches to send over the wire
* or meaningfully present to users.
*/
FETCH_MATCH_COUNT

}
Original file line number Diff line number Diff line change
Expand Up @@ -126,7 +126,7 @@ ScoreExplanation<Solution_, Score_> explain(@NonNull Solution_ solution,
* This is a faster, JSON-friendly version of {@link #explain(Object)}.
*
* @param solution must be fully initialized otherwise an exception is thrown
* @param fetchPolicy if unsure, pick {@link ScoreAnalysisFetchPolicy#FETCH_ALL}
* @param fetchPolicy if unsure, pick {@link ScoreAnalysisFetchPolicy#FETCH_MATCH_COUNT}
* @param solutionUpdatePolicy if unsure, pick {@link SolutionUpdatePolicy#UPDATE_ALL}
* @throws IllegalStateException when constraint matching is disabled or not supported by the underlying score
* calculator, such as {@link EasyScoreCalculator}.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -155,7 +155,7 @@ private void collectMetrics(LocalSearchStepScope<Solution_> stepScope) {
|| solverScope.isMetricEnabled(SolverMetric.CONSTRAINT_MATCH_TOTAL_BEST_SCORE)) {
InnerScoreDirector<Solution_, ?> scoreDirector = stepScope.getScoreDirector();
ScoreDefinition<?> scoreDefinition = solverScope.getScoreDefinition();
if (scoreDirector.isConstraintMatchEnabled()) {
if (scoreDirector.getConstraintMatchPolicy().isEnabled()) {
for (ConstraintMatchTotal<?> constraintMatchTotal : scoreDirector.getConstraintMatchTotalMap()
.values()) {
Tags tags = solverScope.getMonitoringTags().and(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@

import java.util.ArrayList;
import java.util.Collection;
import java.util.Collections;
import java.util.Comparator;
import java.util.List;
import java.util.Map;
Expand Down Expand Up @@ -62,10 +63,18 @@ Explanation of score (%s):
constraintMatchSet.stream()
.sorted(constraintMatchComparator)
.limit(constraintMatchLimit)
.forEach(constraintMatch -> scoreExplanation.append("""
%s: justified with (%s)
""".formatted(constraintMatch.getScore().toShortString(),
constraintMatch.getJustification())));
.forEach(constraintMatch -> {
if (constraintMatch.getJustification() == null) {
scoreExplanation.append("""
%s: unjustified
""".formatted(constraintMatch.getScore().toShortString()));
} else {
scoreExplanation.append("""
%s: justified with (%s)
""".formatted(constraintMatch.getScore().toShortString(),
constraintMatch.getJustification()));
}
});
if (constraintMatchSet.size() > constraintMatchLimit) {
scoreExplanation.append("""
...
Expand Down Expand Up @@ -131,10 +140,13 @@ public DefaultScoreExplanation(Solution_ solution, Score_ score,
for (ConstraintMatchTotal<Score_> constraintMatchTotal : constraintMatchTotalMap.values()) {
for (ConstraintMatch<Score_> constraintMatch : constraintMatchTotal.getConstraintMatchSet()) {
ConstraintJustification justification = constraintMatch.getJustification();
workingConstraintJustificationList.add(justification);
if (justification != null) {
workingConstraintJustificationList.add(justification);
}
}
}
this.constraintJustificationList = workingConstraintJustificationList;
this.constraintJustificationList =
workingConstraintJustificationList.isEmpty() ? Collections.emptyList() : workingConstraintJustificationList;
this.indictmentMap = indictmentMap;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
package ai.timefold.solver.core.impl.score.constraint;

import ai.timefold.solver.core.api.solver.ScoreAnalysisFetchPolicy;

import org.jspecify.annotations.NullMarked;

/**
* Determines whether constraint match is enabled and whether constraint match justification is enabled.
*
* @see ai.timefold.solver.core.api.score.constraint.ConstraintMatch
* @see ai.timefold.solver.core.api.score.stream.ConstraintJustification
*/
@NullMarked
public enum ConstraintMatchPolicy {

DISABLED(false, false),
ENABLED_WITHOUT_JUSTIFICATIONS(true, false),
ENABLED(true, true);

/**
* To achieve the most performance out of the underlying solver,
* the policy should match whatever policy was used for score analysis.
* For example, if the fetch policy specifies that only match counts are necessary and not matches themselves
* ({@link ScoreAnalysisFetchPolicy#FETCH_MATCH_COUNT}),
* we can configure the solver to not produce justifications ({@link #ENABLED_WITHOUT_JUSTIFICATIONS}).
*
* @param scoreAnalysisFetchPolicy
* @return Match policy best suited for the given fetch policy.
*/
public static ConstraintMatchPolicy match(ScoreAnalysisFetchPolicy scoreAnalysisFetchPolicy) {
return switch (scoreAnalysisFetchPolicy) {
case FETCH_MATCH_COUNT, FETCH_SHALLOW -> ENABLED_WITHOUT_JUSTIFICATIONS;
case FETCH_ALL -> ENABLED;
};
}

private final boolean enabled;
private final boolean justificationEnabled;

ConstraintMatchPolicy(boolean enabled, boolean justificationEnabled) {
this.enabled = enabled;
this.justificationEnabled = justificationEnabled;
}

public boolean isEnabled() {
return enabled;
}

public boolean isJustificationEnabled() {
return justificationEnabled;
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@
import ai.timefold.solver.core.api.score.stream.DefaultConstraintJustification;
import ai.timefold.solver.core.api.solver.SolutionManager;

import org.jspecify.annotations.NonNull;

/**
* If possible, prefer using {@link SolutionManager#analyze(Object)} instead.
*
Expand Down Expand Up @@ -71,22 +73,22 @@ public DefaultConstraintMatchTotal(ConstraintRef constraintRef, Score_ constrain
}

@Override
public ConstraintRef getConstraintRef() {
public @NonNull ConstraintRef getConstraintRef() {
return constraintRef;
}

@Override
public Score_ getConstraintWeight() {
public @NonNull Score_ getConstraintWeight() {
return constraintWeight;
}

@Override
public Set<ConstraintMatch<Score_>> getConstraintMatchSet() {
public @NonNull Set<ConstraintMatch<Score_>> getConstraintMatchSet() {
return constraintMatchSet;
}

@Override
public Score_ getScore() {
public @NonNull Score_ getScore() {
return score;
}

Expand Down
Loading

0 comments on commit 06d2c82

Please sign in to comment.