Skip to content

Commit 5bfbc84

Browse files
committed
refactor: no score director in custom phase
1 parent 8f37ec3 commit 5bfbc84

18 files changed

Lines changed: 414 additions & 195 deletions

File tree

Lines changed: 11 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,46 +1,34 @@
11
package ai.timefold.solver.core.api.solver.phase;
22

3-
import java.util.function.BooleanSupplier;
4-
53
import ai.timefold.solver.core.api.domain.solution.PlanningSolution;
6-
import ai.timefold.solver.core.api.score.Score;
7-
import ai.timefold.solver.core.api.score.director.ScoreDirector;
84
import ai.timefold.solver.core.api.solver.Solver;
95
import ai.timefold.solver.core.api.solver.change.ProblemChange;
106
import ai.timefold.solver.core.impl.phase.Phase;
11-
import ai.timefold.solver.core.impl.score.director.InnerScoreDirector;
7+
import ai.timefold.solver.core.preview.api.move.Move;
128

139
import org.jspecify.annotations.NullMarked;
1410

1511
/**
1612
* Runs a custom algorithm as a {@link Phase} of the {@link Solver} that changes the planning variables.
17-
* To change problem facts, use {@link Solver#addProblemChange(ProblemChange)} instead.
18-
* <p>
19-
* To add custom properties, configure custom properties and add public setters for them.
13+
* To change problem facts and to add or remove entities, use {@link Solver#addProblemChange(ProblemChange)} instead.
2014
*
2115
* @param <Solution_> the solution type, the class with the {@link PlanningSolution} annotation
2216
*/
2317
@NullMarked
2418
public interface PhaseCommand<Solution_> {
2519

2620
/**
27-
* Changes {@link PlanningSolution working solution} of {@link ScoreDirector#getWorkingSolution()}.
28-
* When the {@link PlanningSolution working solution} is modified,
29-
* the {@link ScoreDirector} must be correctly notified
30-
* (through {@link ScoreDirector#beforeVariableChanged(Object, String)} and
31-
* {@link ScoreDirector#afterVariableChanged(Object, String)}),
32-
* otherwise calculated {@link Score}s will be corrupted.
21+
* Changes the current {@link PhaseCommandContext#getWorkingSolution() working solution}.
22+
* The solver is notified of the changes through {@link PhaseCommandContext},
23+
* specifically through {@link PhaseCommandContext#execute(Move)}.
24+
* Any other modifications to the working solution are strictly forbidden
25+
* and will likely cause the solver to be in an inconsistent state and throw an exception later on.
3326
* <p>
34-
* Don't forget to call {@link ScoreDirector#triggerVariableListeners()} after each set of changes
35-
* (especially before every {@link InnerScoreDirector#calculateScore()} call)
36-
* to ensure all shadow variables are updated.
27+
* Don't forget to check {@link PhaseCommandContext#isPhaseTerminated() termination status} frequently
28+
* to allow the solver to gracefully terminate when necessary.
3729
*
38-
* @param scoreDirector the {@link ScoreDirector} that needs to get notified of the changes.
39-
* @param isPhaseTerminated long-running command implementations should check this periodically
40-
* and terminate early if it returns true.
41-
* Otherwise the terminations configured by the user will have no effect,
42-
* as the solver can only terminate itself when a command has ended.
30+
* @param context the context of the command, providing access to the working solution and allowing move execution
4331
*/
44-
void changeWorkingSolution(ScoreDirector<Solution_> scoreDirector, BooleanSupplier isPhaseTerminated);
32+
void changeWorkingSolution(PhaseCommandContext<Solution_> context);
4533

4634
}
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package ai.timefold.solver.core.api.solver.phase;
2+
3+
import java.util.function.Function;
4+
5+
import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel;
6+
import ai.timefold.solver.core.preview.api.move.Move;
7+
import ai.timefold.solver.core.preview.api.move.Rebaser;
8+
9+
import org.jspecify.annotations.NullMarked;
10+
import org.jspecify.annotations.Nullable;
11+
12+
/**
13+
* The context of a command that is executed during a custom phase.
14+
* It provides access to the working solution and allows executing moves.
15+
*
16+
* @param <Solution_> the type of the solution
17+
* @see PhaseCommand
18+
*/
19+
@NullMarked
20+
public interface PhaseCommandContext<Solution_> {
21+
22+
/**
23+
* Returns the meta-model of the {@link #getWorkingSolution() working solution}.
24+
*
25+
* @return the meta-model of the working solution
26+
*/
27+
PlanningSolutionMetaModel<Solution_> getSolutionMetaModel();
28+
29+
/**
30+
* Returns the current working solution.
31+
* It must not be modified directly,
32+
* but only through {@link #execute(Move)} or {@link #executeTemporarily(Move, Function)}.
33+
* Direct modifications will cause the solver to be in an inconsistent state and likely throw an exception later on.
34+
*
35+
* @return the current working solution
36+
*/
37+
Solution_ getWorkingSolution();
38+
39+
/**
40+
* Long-running command implementations should check this periodically and terminate early if it returns true.
41+
* Otherwise the terminations configured by the user will have no effect,
42+
* as the solver can only terminate itself when a command has ended.
43+
*
44+
* @return true if the solver has requested the phase to terminate,
45+
* for example because the time limit has been reached.
46+
*/
47+
boolean isPhaseTerminated();
48+
49+
/**
50+
* As defined by {@link #execute(Move, boolean)},
51+
* but with the guarantee of a fresh score.
52+
*/
53+
default void execute(Move<Solution_> move) {
54+
execute(move, true);
55+
}
56+
57+
/**
58+
* Executes the given move and updates the working solution,
59+
* optionally without recalculating the score for performance reasons.
60+
*
61+
* @param move the move to execute
62+
* @param guaranteeFreshScore if true, the score of {@link #getWorkingSolution()} after this method returns
63+
* is guaranteed to be up-to-date;
64+
* otherwise it may be stale as the solver will skip recalculating it for performance reasons.
65+
*/
66+
void execute(Move<Solution_> move, boolean guaranteeFreshScore);
67+
68+
/**
69+
* As defined by {@link #executeTemporarily(Move, Function, boolean)},
70+
* with the guarantee of a fresh score.
71+
*/
72+
default <Result_> @Nullable Result_ executeTemporarily(Move<Solution_> move,
73+
Function<Solution_, @Nullable Result_> temporarySolutionConsumer) {
74+
return executeTemporarily(move, temporarySolutionConsumer, true);
75+
}
76+
77+
/**
78+
* Executes the given move temporarily and returns the result of the given consumer.
79+
* The working solution is reverted to its original state after the consumer has been executed,
80+
* optionally without recalculating the score for performance reasons.
81+
*
82+
* @param move the move to execute temporarily
83+
* @param temporarySolutionConsumer the consumer to execute with the temporarily modified solution;
84+
* this solution must not be modified any further.
85+
* @param guaranteeFreshScore if true, the score of {@link #getWorkingSolution()} after this method returns
86+
* is guaranteed to be up-to-date;
87+
* otherwise it may be stale as the solver will skip recalculating it for performance reasons.
88+
* @return the result of the consumer
89+
*/
90+
<Result_> @Nullable Result_ executeTemporarily(Move<Solution_> move,
91+
Function<Solution_, @Nullable Result_> temporarySolutionConsumer, boolean guaranteeFreshScore);
92+
93+
/**
94+
* As defined by {@link Rebaser#rebase(Object)}, but for the working solution of this context.
95+
*/
96+
<T> @Nullable T lookupWorkingObject(@Nullable T original);
97+
98+
}

core/src/main/java/ai/timefold/solver/core/impl/move/AbstractMove.java

Lines changed: 2 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,7 @@ public final String describe() {
2727
var metaModels = variableMetaModels();
2828
var substring = switch (metaModels.size()) {
2929
case 0 -> "";
30-
case 1 -> OPENING_PARENTHESES + getVariableDescriptor(metaModels.get(0)).getSimpleEntityAndVariableName()
30+
case 1 -> OPENING_PARENTHESES + getVariableDescriptor(metaModels.getFirst()).getSimpleEntityAndVariableName()
3131
+ CLOSING_PARENTHESES;
3232
default -> {
3333
var stringBuilder = new StringBuilder()
@@ -37,7 +37,7 @@ public final String describe() {
3737
if (first) {
3838
first = false;
3939
} else {
40-
stringBuilder.append(", ");
40+
stringBuilder.append(",");
4141
}
4242
stringBuilder.append(getVariableDescriptor(variableMetaModel).getSimpleEntityAndVariableName());
4343
}
@@ -48,8 +48,6 @@ public final String describe() {
4848
return getClass().getSimpleName() + substring;
4949
}
5050

51-
public abstract String toString();
52-
5351
public abstract List<? extends GenuineVariableMetaModel<Solution_, ?, ?>> variableMetaModels();
5452

5553
@SuppressWarnings("unchecked")

core/src/main/java/ai/timefold/solver/core/impl/move/MoveDirector.java

Lines changed: 56 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,9 @@
11
package ai.timefold.solver.core.impl.move;
22

3+
import java.util.List;
34
import java.util.Objects;
45
import java.util.function.BiFunction;
6+
import java.util.function.Function;
57

68
import ai.timefold.solver.core.api.score.Score;
79
import ai.timefold.solver.core.impl.domain.entity.descriptor.EntityDescriptor;
@@ -16,6 +18,7 @@
1618
import ai.timefold.solver.core.preview.api.domain.metamodel.ElementPosition;
1719
import ai.timefold.solver.core.preview.api.domain.metamodel.GenuineVariableMetaModel;
1820
import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel;
21+
import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel;
1922
import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningVariableMetaModel;
2023
import ai.timefold.solver.core.preview.api.domain.metamodel.UnassignedElement;
2124
import ai.timefold.solver.core.preview.api.move.Move;
@@ -46,6 +49,11 @@ public MoveDirector(InnerScoreDirector<Solution_, Score_> scoreDirector) {
4649
}
4750
}
4851

52+
@Override
53+
public PlanningSolutionMetaModel<Solution_> getSolutionMetaModel() {
54+
return backingScoreDirector.getSolutionDescriptor().getMetaModel();
55+
}
56+
4957
@Override
5058
public final <Entity_, Value_> void assignValueAndAdd(
5159
PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel, Value_ planningValue,
@@ -65,6 +73,26 @@ public final <Entity_, Value_> void assignValueAndAdd(
6573
externalScoreDirector.triggerVariableListeners();
6674
}
6775

76+
@Override
77+
public <Entity_, Value_> void assignValuesAndAdd(
78+
PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel, List<Value_> values,
79+
Entity_ destinationEntity, int destinationIndex) {
80+
var variableDescriptor =
81+
((DefaultPlanningListVariableMetaModel<Solution_, Entity_, Value_>) variableMetaModel).variableDescriptor();
82+
for (var value : values) {
83+
if (!(getPositionOf(variableMetaModel, value) instanceof UnassignedElement)) {
84+
throw new IllegalStateException("Cannot assign an already assigned value (%s).".formatted(value));
85+
}
86+
externalScoreDirector.beforeListVariableElementAssigned(variableDescriptor, value);
87+
}
88+
externalScoreDirector.beforeListVariableChanged(variableDescriptor, destinationEntity, 0, 0);
89+
variableDescriptor.getValue(destinationEntity).addAll(destinationIndex, values);
90+
externalScoreDirector.afterListVariableChanged(variableDescriptor, destinationEntity, 0, values.size());
91+
for (var value : values) {
92+
externalScoreDirector.afterListVariableElementAssigned(variableDescriptor, value);
93+
}
94+
}
95+
6896
@Override
6997
public final <Entity_, Value_> void assignValueAndSet(
7098
PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel, Value_ planningValue,
@@ -273,10 +301,24 @@ public <Entity_, Value_> boolean isValueInRange(GenuineVariableMetaModel<Solutio
273301

274302
/**
275303
* Execute a given move and make sure shadow variables are up to date after that.
304+
* Does not run a score calculation.
276305
*/
277306
public final void execute(Move<Solution_> move) {
307+
execute(move, false);
308+
}
309+
310+
/**
311+
* Execute a given move and make sure shadow variables are up to date after that.
312+
*
313+
* @param guaranteeFreshScore if true, a score calculation is forced after executing the move,
314+
* to ensure the score is up to date.
315+
*/
316+
public final void execute(Move<Solution_> move, boolean guaranteeFreshScore) {
278317
move.execute(this);
279318
externalScoreDirector.triggerVariableListeners();
319+
if (guaranteeFreshScore) {
320+
backingScoreDirector.calculateScore();
321+
}
280322
}
281323

282324
public final InnerScore<Score_> executeTemporary(Move<Solution_> move) {
@@ -289,11 +331,22 @@ public final InnerScore<Score_> executeTemporary(Move<Solution_> move) {
289331

290332
public <Result_> Result_ executeTemporary(Move<Solution_> move,
291333
TemporaryMovePostprocessor<Solution_, Score_, Result_> postprocessor) {
334+
try (var ephemeralMoveDirector = ephemeral()) {
335+
ephemeralMoveDirector.execute(move);
336+
var score = backingScoreDirector.calculateScore();
337+
return postprocessor.apply(score, ephemeralMoveDirector.createUndoMove());
338+
}
339+
}
340+
341+
public <Result_> @Nullable Result_ executeTemporary(Move<Solution_> move,
342+
Function<Solution_, @Nullable Result_> postprocessor, boolean guaranteeFreshScore) {
292343
var ephemeralMoveDirector = ephemeral();
293-
ephemeralMoveDirector.execute(move);
294-
var score = backingScoreDirector.calculateScore();
295-
var result = postprocessor.apply(score, ephemeralMoveDirector.createUndoMove());
344+
ephemeralMoveDirector.execute(move, true);
345+
var result = postprocessor.apply(backingScoreDirector.getWorkingSolution());
296346
ephemeralMoveDirector.close(); // This undoes the move.
347+
if (guaranteeFreshScore) {
348+
backingScoreDirector.calculateScore();
349+
}
297350
return result;
298351
}
299352

core/src/main/java/ai/timefold/solver/core/impl/phase/custom/CustomPhaseCommand.java

Lines changed: 0 additions & 25 deletions
This file was deleted.

core/src/main/java/ai/timefold/solver/core/impl/phase/custom/DefaultCustomPhase.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -84,9 +84,9 @@ public void phaseStarted(AbstractPhaseScope<Solution_> phaseScope) {
8484
}
8585

8686
private void doStep(CustomStepScope<Solution_> stepScope, PhaseCommand<Solution_> customPhaseCommand) {
87-
var scoreDirector = stepScope.getScoreDirector();
88-
customPhaseCommand.changeWorkingSolution(scoreDirector,
87+
var commandContext = new DefaultPhaseCommandContext<>(stepScope.getMoveDirector(),
8988
() -> phaseTermination.isPhaseTerminated(stepScope.getPhaseScope()));
89+
customPhaseCommand.changeWorkingSolution(commandContext);
9090
calculateWorkingStepScore(stepScope, customPhaseCommand);
9191
var solver = stepScope.getPhaseScope().getSolverScope().getSolver();
9292
solver.getBestSolutionRecaller().processWorkingSolutionDuringStep(stepScope);
Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,57 @@
1+
package ai.timefold.solver.core.impl.phase.custom;
2+
3+
import java.util.Objects;
4+
import java.util.function.BooleanSupplier;
5+
import java.util.function.Function;
6+
7+
import ai.timefold.solver.core.api.solver.phase.PhaseCommandContext;
8+
import ai.timefold.solver.core.impl.move.MoveDirector;
9+
import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel;
10+
import ai.timefold.solver.core.preview.api.move.Move;
11+
12+
import org.jspecify.annotations.NullMarked;
13+
import org.jspecify.annotations.Nullable;
14+
15+
@NullMarked
16+
final class DefaultPhaseCommandContext<Solution_> implements PhaseCommandContext<Solution_> {
17+
18+
private final MoveDirector<Solution_, ?> moveDirector;
19+
private final BooleanSupplier isPhaseTerminated;
20+
21+
public DefaultPhaseCommandContext(MoveDirector<Solution_, ?> moveDirector, BooleanSupplier isPhaseTerminated) {
22+
this.moveDirector = Objects.requireNonNull(moveDirector);
23+
this.isPhaseTerminated = Objects.requireNonNull(isPhaseTerminated);
24+
}
25+
26+
@Override
27+
public PlanningSolutionMetaModel<Solution_> getSolutionMetaModel() {
28+
return moveDirector.getSolutionMetaModel();
29+
}
30+
31+
@Override
32+
public Solution_ getWorkingSolution() {
33+
return moveDirector.getScoreDirector().getWorkingSolution();
34+
}
35+
36+
@Override
37+
public boolean isPhaseTerminated() {
38+
return isPhaseTerminated.getAsBoolean();
39+
}
40+
41+
@Override
42+
public <T> T lookupWorkingObject(T original) {
43+
return moveDirector.rebase(original);
44+
}
45+
46+
@Override
47+
public void execute(Move<Solution_> move, boolean guaranteeFreshScore) {
48+
moveDirector.execute(move, guaranteeFreshScore);
49+
}
50+
51+
@Override
52+
public @Nullable <Result_> Result_ executeTemporarily(Move<Solution_> move,
53+
Function<Solution_, @Nullable Result_> temporarySolutionConsumer, boolean guaranteeFreshScore) {
54+
return moveDirector.executeTemporary(move, temporarySolutionConsumer, guaranteeFreshScore);
55+
}
56+
57+
}

0 commit comments

Comments
 (0)