Skip to content

Commit

Permalink
Feat: Allow phase commands to read termination status (#1422)
Browse files Browse the repository at this point in the history
  • Loading branch information
triceo authored Feb 26, 2025
1 parent d8d5d55 commit 85865a5
Show file tree
Hide file tree
Showing 24 changed files with 375 additions and 187 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -20,11 +20,11 @@
import ai.timefold.solver.core.api.solver.DivertingClassLoader;
import ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig;
import ai.timefold.solver.core.config.solver.SolverConfig;
import ai.timefold.solver.core.impl.phase.custom.NoChangeCustomPhaseCommand;
import ai.timefold.solver.core.impl.testdata.domain.TestdataEntity;
import ai.timefold.solver.core.impl.testdata.domain.TestdataSolution;
import ai.timefold.solver.core.impl.testdata.domain.TestdataValue;
import ai.timefold.solver.core.impl.testdata.util.PlannerTestUtils;
import ai.timefold.solver.core.impl.testutil.NoChangeCustomPhaseCommand;

import org.jspecify.annotations.NonNull;
import org.junit.jupiter.api.BeforeAll;
Expand Down
38 changes: 38 additions & 0 deletions core/src/build/revapi-differences.json
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,44 @@
"oldValue": "{\"terminationClass\", \"terminationCompositionStyle\", \"spentLimit\", \"millisecondsSpentLimit\", \"secondsSpentLimit\", \"minutesSpentLimit\", \"hoursSpentLimit\", \"daysSpentLimit\", \"unimprovedSpentLimit\", \"unimprovedMillisecondsSpentLimit\", \"unimprovedSecondsSpentLimit\", \"unimprovedMinutesSpentLimit\", \"unimprovedHoursSpentLimit\", \"unimprovedDaysSpentLimit\", \"unimprovedScoreDifferenceThreshold\", \"bestScoreLimit\", \"bestScoreFeasible\", \"stepCountLimit\", \"unimprovedStepCountLimit\", \"scoreCalculationCountLimit\", \"terminationConfigList\"}",
"newValue": "{\"terminationClass\", \"terminationCompositionStyle\", \"diminishedReturnsConfig\", \"spentLimit\", \"millisecondsSpentLimit\", \"secondsSpentLimit\", \"minutesSpentLimit\", \"hoursSpentLimit\", \"daysSpentLimit\", \"unimprovedSpentLimit\", \"unimprovedMillisecondsSpentLimit\", \"unimprovedSecondsSpentLimit\", \"unimprovedMinutesSpentLimit\", \"unimprovedHoursSpentLimit\", \"unimprovedDaysSpentLimit\", \"unimprovedScoreDifferenceThreshold\", \"bestScoreLimit\", \"bestScoreFeasible\", \"stepCountLimit\", \"unimprovedStepCountLimit\", \"scoreCalculationCountLimit\", \"moveCountLimit\", \"terminationConfigList\"}",
"justification": "Added support for the new diminished returns termination type"
},
{
"ignore": true,
"code": "java.method.returnTypeTypeParametersChanged",
"old": "method java.util.List<java.lang.Class<? extends ai.timefold.solver.core.impl.phase.custom.CustomPhaseCommand>> ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig::getCustomPhaseCommandClassList()",
"new": "method java.util.List<java.lang.Class<? extends ai.timefold.solver.core.api.solver.phase.PhaseCommand>> ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig::getCustomPhaseCommandClassList()",
"justification": "PhaseCommand is the new interface for custom phase commands, CustomPhaseCommand is deprecated and extends it"
},
{
"ignore": true,
"code": "java.method.returnTypeTypeParametersChanged",
"old": "method java.util.List<ai.timefold.solver.core.impl.phase.custom.CustomPhaseCommand> ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig::getCustomPhaseCommandList()",
"new": "method java.util.List<? extends ai.timefold.solver.core.api.solver.phase.PhaseCommand> ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig::getCustomPhaseCommandList()",
"justification": "PhaseCommand is the new interface for custom phase commands, CustomPhaseCommand is deprecated and extends it"
},
{
"ignore": true,
"code": "java.method.parameterTypeParameterChanged",
"old": "parameter void ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig::setCustomPhaseCommandClassList(===java.util.List<java.lang.Class<? extends ai.timefold.solver.core.impl.phase.custom.CustomPhaseCommand>>===)",
"new": "parameter void ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig::setCustomPhaseCommandClassList(===java.util.List<java.lang.Class<? extends ai.timefold.solver.core.api.solver.phase.PhaseCommand>>===)",
"parameterIndex": "0",
"justification": "PhaseCommand is the new interface for custom phase commands, CustomPhaseCommand is deprecated and extends it"
},
{
"ignore": true,
"code": "java.method.parameterTypeParameterChanged",
"old": "parameter void ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig::setCustomPhaseCommandList(===java.util.List<ai.timefold.solver.core.impl.phase.custom.CustomPhaseCommand>===)",
"new": "parameter void ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig::setCustomPhaseCommandList(===java.util.List<? extends ai.timefold.solver.core.api.solver.phase.PhaseCommand>===)",
"parameterIndex": "0",
"justification": "PhaseCommand is the new interface for custom phase commands, CustomPhaseCommand is deprecated and extends it"
},
{
"ignore": true,
"code": "java.annotation.added",
"old": "class ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig",
"new": "class ai.timefold.solver.core.config.phase.custom.CustomPhaseConfig",
"annotation": "@org.jspecify.annotations.NullMarked",
"justification": "Less verbose nullability, no practical impact."
}
]
}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
package ai.timefold.solver.core.api.solver.phase;

import java.util.function.BooleanSupplier;

import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
import ai.timefold.solver.core.api.score.Score;
import ai.timefold.solver.core.api.score.director.ScoreDirector;
import ai.timefold.solver.core.api.solver.Solver;
import ai.timefold.solver.core.api.solver.change.ProblemChange;
import ai.timefold.solver.core.impl.phase.Phase;
import ai.timefold.solver.core.impl.score.director.InnerScoreDirector;

import org.jspecify.annotations.NullMarked;

/**
* Runs a custom algorithm as a {@link Phase} of the {@link Solver} that changes the planning variables.
* To change problem facts, use {@link Solver#addProblemChange(ProblemChange)} instead.
* <p>
* To add custom properties, configure custom properties and add public setters for them.
*
* @param <Solution_> the solution type, the class with the {@link PlanningSolution} annotation
*/
@NullMarked
public interface PhaseCommand<Solution_> {

/**
* Changes {@link PlanningSolution working solution} of {@link ScoreDirector#getWorkingSolution()}.
* When the {@link PlanningSolution working solution} is modified,
* the {@link ScoreDirector} must be correctly notified
* (through {@link ScoreDirector#beforeVariableChanged(Object, String)} and
* {@link ScoreDirector#afterVariableChanged(Object, String)}),
* otherwise calculated {@link Score}s will be corrupted.
* <p>
* Don't forget to call {@link ScoreDirector#triggerVariableListeners()} after each set of changes
* (especially before every {@link InnerScoreDirector#calculateScore()} call)
* to ensure all shadow variables are updated.
*
* @param scoreDirector the {@link ScoreDirector} that needs to get notified of the changes.
* @param isPhaseTerminated long-running command implementations should check this periodically
* and terminate early if it returns true.
* Otherwise the terminations configured by the user will have no effect,
* as the solver can only terminate itself when a command has ended.
*/
void changeWorkingSolution(ScoreDirector<Solution_> scoreDirector, BooleanSupplier isPhaseTerminated);

}
Original file line number Diff line number Diff line change
Expand Up @@ -2,8 +2,14 @@

import java.util.function.Consumer;

import ai.timefold.solver.core.impl.phase.NoChangePhase;

import org.jspecify.annotations.NonNull;

/**
* @deprecated Deprecated on account of deprecating {@link NoChangePhase}.
*/
@Deprecated(forRemoval = true, since = "1.20.0")
public class NoChangePhaseConfig extends PhaseConfig<NoChangePhaseConfig> {

public static final String XML_ELEMENT_NAME = "noChangePhase";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,18 +12,19 @@
import jakarta.xml.bind.annotation.XmlType;
import jakarta.xml.bind.annotation.adapters.XmlJavaTypeAdapter;

import ai.timefold.solver.core.api.solver.phase.PhaseCommand;
import ai.timefold.solver.core.config.phase.PhaseConfig;
import ai.timefold.solver.core.config.util.ConfigUtils;
import ai.timefold.solver.core.impl.io.jaxb.adapter.JaxbCustomPropertiesAdapter;
import ai.timefold.solver.core.impl.phase.custom.CustomPhaseCommand;

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

@XmlType(propOrder = {
"customPhaseCommandClassList",
"customProperties",
})
@NullMarked
public class CustomPhaseConfig extends PhaseConfig<CustomPhaseConfig> {

public static final String XML_ELEMENT_NAME = "customPhase";
Expand All @@ -32,100 +33,91 @@ public class CustomPhaseConfig extends PhaseConfig<CustomPhaseConfig> {
// and also because the input config file should match the output config file

@XmlElement(name = "customPhaseCommandClass")
protected List<Class<? extends CustomPhaseCommand>> customPhaseCommandClassList = null;
protected @Nullable List<Class<? extends PhaseCommand>> customPhaseCommandClassList = null;

@XmlJavaTypeAdapter(JaxbCustomPropertiesAdapter.class)
protected Map<String, String> customProperties = null;
protected @Nullable Map<String, String> customProperties = null;

@XmlTransient
protected List<CustomPhaseCommand> customPhaseCommandList = null;
protected @Nullable List<? extends PhaseCommand> customPhaseCommandList = null;

// ************************************************************************
// Constructors and simple getters/setters
// ************************************************************************

public @Nullable List<Class<? extends CustomPhaseCommand>> getCustomPhaseCommandClassList() {
public @Nullable List<Class<? extends PhaseCommand>> getCustomPhaseCommandClassList() {
return customPhaseCommandClassList;
}

public void setCustomPhaseCommandClassList(
@Nullable List<Class<? extends CustomPhaseCommand>> customPhaseCommandClassList) {
public void setCustomPhaseCommandClassList(@Nullable List<Class<? extends PhaseCommand>> customPhaseCommandClassList) {
this.customPhaseCommandClassList = customPhaseCommandClassList;
}

public @Nullable Map<@NonNull String, @NonNull String> getCustomProperties() {
public @Nullable Map<String, String> getCustomProperties() {
return customProperties;
}

public void setCustomProperties(@Nullable Map<@NonNull String, @NonNull String> customProperties) {
public void setCustomProperties(@Nullable Map<String, String> customProperties) {
this.customProperties = customProperties;
}

public @Nullable List<@NonNull CustomPhaseCommand> getCustomPhaseCommandList() {
public @Nullable List<? extends PhaseCommand> getCustomPhaseCommandList() {
return customPhaseCommandList;
}

public void setCustomPhaseCommandList(@Nullable List<@NonNull CustomPhaseCommand> customPhaseCommandList) {
public void setCustomPhaseCommandList(@Nullable List<? extends PhaseCommand> customPhaseCommandList) {
this.customPhaseCommandList = customPhaseCommandList;
}

// ************************************************************************
// With methods
// ************************************************************************

public @NonNull CustomPhaseConfig withCustomPhaseCommandClassList(
@NonNull List<@NonNull Class<? extends CustomPhaseCommand>> customPhaseCommandClassList) {
public CustomPhaseConfig withCustomPhaseCommandClassList(List<Class<? extends PhaseCommand>> customPhaseCommandClassList) {
this.customPhaseCommandClassList = customPhaseCommandClassList;
return this;
}

public @NonNull CustomPhaseConfig withCustomProperties(@NonNull Map<@NonNull String, @NonNull String> customProperties) {
public CustomPhaseConfig withCustomProperties(Map<String, String> customProperties) {
this.customProperties = customProperties;
return this;
}

public @NonNull CustomPhaseConfig
withCustomPhaseCommandList(@NonNull List<@NonNull CustomPhaseCommand> customPhaseCommandList) {
public CustomPhaseConfig withCustomPhaseCommandList(List<? extends PhaseCommand> customPhaseCommandList) {
boolean hasNullCommand = Objects.requireNonNullElse(customPhaseCommandList, Collections.emptyList())
.stream().anyMatch(Objects::isNull);
if (hasNullCommand) {
throw new IllegalArgumentException(
"Custom phase commands (" + customPhaseCommandList + ") must not contain a null element.");
}
this.customPhaseCommandList = customPhaseCommandList;
this.customPhaseCommandList = List.copyOf(customPhaseCommandList);
return this;
}

public <Solution_> @NonNull CustomPhaseConfig
withCustomPhaseCommands(@NonNull CustomPhaseCommand<Solution_> @NonNull... customPhaseCommands) {
boolean hasNullCommand = Arrays.stream(customPhaseCommands).anyMatch(Objects::isNull);
if (hasNullCommand) {
throw new IllegalArgumentException(
"Custom phase commands (" + Arrays.toString(customPhaseCommands) + ") must not contain a null element.");
}
this.customPhaseCommandList = Arrays.asList(customPhaseCommands);
return this;
public <Solution_> CustomPhaseConfig withCustomPhaseCommands(PhaseCommand<Solution_>... customPhaseCommands) {
return withCustomPhaseCommandList(Arrays.asList(customPhaseCommands));
}

@SuppressWarnings({ "unchecked", "rawtypes" })
@Override
public @NonNull CustomPhaseConfig inherit(@NonNull CustomPhaseConfig inheritedConfig) {
public CustomPhaseConfig inherit(CustomPhaseConfig inheritedConfig) {
super.inherit(inheritedConfig);
customPhaseCommandClassList = ConfigUtils.inheritMergeableListProperty(
customPhaseCommandClassList, inheritedConfig.getCustomPhaseCommandClassList());
customPhaseCommandList = ConfigUtils.inheritMergeableListProperty(
customPhaseCommandList, inheritedConfig.getCustomPhaseCommandList());
customPhaseCommandList, (List) inheritedConfig.getCustomPhaseCommandList());
customProperties = ConfigUtils.inheritMergeableMapProperty(
customProperties, inheritedConfig.getCustomProperties());
return this;
}

@Override
public @NonNull CustomPhaseConfig copyConfig() {
public CustomPhaseConfig copyConfig() {
return new CustomPhaseConfig().inherit(this);
}

@Override
public void visitReferencedClasses(@NonNull Consumer<Class<?>> classVisitor) {
public void visitReferencedClasses(Consumer<Class<?>> classVisitor) {
if (terminationConfig != null) {
terminationConfig.visitReferencedClasses(classVisitor);
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,9 +8,9 @@
* A {@link NoChangePhase} is a {@link Phase} which does nothing.
*
* @param <Solution_> the solution type, the class with the {@link PlanningSolution} annotation
* @see Phase
* @see AbstractPhase
* @deprecated Deprecated on account of having no use.
*/
@Deprecated(forRemoval = true, since = "1.20.0")
public class NoChangePhase<Solution_> extends AbstractPhase<Solution_> {

private NoChangePhase(Builder<Solution_> builder) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,12 @@
import ai.timefold.solver.core.impl.solver.recaller.BestSolutionRecaller;
import ai.timefold.solver.core.impl.solver.termination.Termination;

/**
*
* @param <Solution_>
* @deprecated Deprecated on account of deprecating {@link NoChangePhase}.
*/
@Deprecated(forRemoval = true, since = "1.20.0")
public class NoChangePhaseFactory<Solution_> extends AbstractPhaseFactory<Solution_, NoChangePhaseConfig> {

public NoChangePhaseFactory(NoChangePhaseConfig phaseConfig) {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,14 +1,15 @@
package ai.timefold.solver.core.impl.phase.custom;

import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
import ai.timefold.solver.core.api.solver.phase.PhaseCommand;
import ai.timefold.solver.core.impl.phase.AbstractPhase;
import ai.timefold.solver.core.impl.phase.Phase;
import ai.timefold.solver.core.impl.phase.PossiblyInitializingPhase;

import org.jspecify.annotations.NullMarked;

/**
* A {@link CustomPhase} is a {@link Phase} which uses {@link CustomPhaseCommand}s.
* A {@link CustomPhase} is a {@link Phase} which uses {@link PhaseCommand}s.
*
* @param <Solution_> the solution type, the class with the {@link PlanningSolution} annotation
* @see Phase
Expand Down
Original file line number Diff line number Diff line change
@@ -1,38 +1,25 @@
package ai.timefold.solver.core.impl.phase.custom;

import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
import ai.timefold.solver.core.api.score.Score;
import java.util.function.BooleanSupplier;

import ai.timefold.solver.core.api.score.director.ScoreDirector;
import ai.timefold.solver.core.api.solver.ProblemFactChange;
import ai.timefold.solver.core.api.solver.Solver;
import ai.timefold.solver.core.impl.phase.Phase;
import ai.timefold.solver.core.impl.score.director.InnerScoreDirector;
import ai.timefold.solver.core.api.solver.phase.PhaseCommand;

import org.jspecify.annotations.NullMarked;

/**
* Runs a custom algorithm as a {@link Phase} of the {@link Solver} that changes the planning variables.
* Do not abuse to change the problems facts,
* instead use {@link Solver#addProblemFactChange(ProblemFactChange)} for that.
* <p>
* To add custom properties, configure custom properties and add public setters for them.
*
* @param <Solution_> the solution type, the class with the {@link PlanningSolution} annotation
* @deprecated Use {@link PhaseCommand} instead.
*/
@FunctionalInterface
public interface CustomPhaseCommand<Solution_> {
@NullMarked
@Deprecated(forRemoval = true, since = "1.20.0")
public interface CustomPhaseCommand<Solution_> extends PhaseCommand<Solution_> {

@Override
default void changeWorkingSolution(ScoreDirector<Solution_> scoreDirector, BooleanSupplier isPhaseTerminated) {
changeWorkingSolution(scoreDirector);
}

/**
* Changes {@link PlanningSolution working solution} of {@link ScoreDirector#getWorkingSolution()}.
* When the {@link PlanningSolution working solution} is modified, the {@link ScoreDirector} must be correctly notified
* (through {@link ScoreDirector#beforeVariableChanged(Object, String)} and
* {@link ScoreDirector#afterVariableChanged(Object, String)}),
* otherwise calculated {@link Score}s will be corrupted.
* <p>
* Don't forget to call {@link ScoreDirector#triggerVariableListeners()} after each set of changes
* (especially before every {@link InnerScoreDirector#calculateScore()} call)
* to ensure all shadow variables are updated.
*
* @param scoreDirector never null, the {@link ScoreDirector} that needs to get notified of the changes.
*/
void changeWorkingSolution(ScoreDirector<Solution_> scoreDirector);

}
Loading

0 comments on commit 85865a5

Please sign in to comment.