Skip to content

Commit d54108e

Browse files
committed
feat: new SolutionUpdatePolicy to force update of shadow vars
1 parent 3e8eeef commit d54108e

File tree

11 files changed

+98
-22
lines changed

11 files changed

+98
-22
lines changed

core/src/main/java/ai/timefold/solver/core/api/solver/SolutionManager.java

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -111,8 +111,8 @@ static <Solution_> void updateShadowVariables(@NonNull Class<Solution_> solution
111111
}
112112

113113
/**
114-
* Same as {@link #updateShadowVariables(Class, Object...)},
115-
* this method accepts a solution rather than a list of entities.
114+
* Equivalent to {@link SolutionManager#update(Object, SolutionUpdatePolicy)}
115+
* with {@link SolutionUpdatePolicy#RESET_SHADOW_VARIABLES_ONLY}.
116116
*
117117
* @param solution the solution
118118
*/

core/src/main/java/ai/timefold/solver/core/api/solver/SolutionUpdatePolicy.java

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,11 +40,23 @@ public enum SolutionUpdatePolicy {
4040
/**
4141
* Runs variable listeners on all planning entities and problem facts,
4242
* updates shadow variables.
43+
* Assumes an uninitialized solution;
44+
* for solutions where some shadow variables are already filled in,
45+
* use {@link #RESET_SHADOW_VARIABLES_ONLY} instead.
46+
* <p>
4347
* Does not update score;
4448
* the solution will keep the current score, even if it is stale or null.
4549
* To avoid this, use {@link #UPDATE_ALL} instead.
4650
*/
4751
UPDATE_SHADOW_VARIABLES_ONLY(false, true),
52+
/**
53+
* A specialized variant of {@link #UPDATE_SHADOW_VARIABLES_ONLY}, which clears the existing shadow variables first.
54+
* This is significantly slower than the former,
55+
* but it avoids an issue with dependent shadow variables not being recomputed
56+
* if their source value did not change.
57+
* This is only useful for solutions where some shadow variables are already filled in.
58+
*/
59+
RESET_SHADOW_VARIABLES_ONLY(false, true),
4860
/**
4961
* Does not run anything.
5062
* Improves performance during {@link SolutionManager#analyze(Object, ScoreAnalysisFetchPolicy, SolutionUpdatePolicy)}
@@ -75,4 +87,9 @@ public boolean isScoreUpdateEnabled() {
7587
public boolean isShadowVariableUpdateEnabled() {
7688
return shadowVariableUpdateEnabled;
7789
}
90+
91+
public boolean isShadowVariableUpdateForced() {
92+
return isShadowVariableUpdateEnabled() && this == RESET_SHADOW_VARIABLES_ONLY;
93+
}
94+
7895
}

core/src/main/java/ai/timefold/solver/core/impl/domain/solution/descriptor/SolutionDescriptor.java

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -99,6 +99,16 @@ public class SolutionDescriptor<Solution_> {
9999
PlanningEntityCollectionProperty.class,
100100
PlanningScore.class };
101101

102+
@SuppressWarnings("unchecked")
103+
public static <Solution_> SolutionDescriptor<Solution_> buildSolutionDescriptorFromSolution(Solution_ solution) {
104+
var enabledPreviewFeatures = EnumSet.of(PreviewFeature.DECLARATIVE_SHADOW_VARIABLES);
105+
var solutionClass = (Class<Solution_>) solution.getClass();
106+
var initialSolutionDescriptor = buildSolutionDescriptor(enabledPreviewFeatures, solutionClass);
107+
var entityClassSet = new LinkedHashSet<Class<?>>();
108+
initialSolutionDescriptor.visitAllEntities(solution, e -> entityClassSet.add(e.getClass()));
109+
return buildSolutionDescriptor(enabledPreviewFeatures, solutionClass, entityClassSet.toArray(new Class<?>[0]));
110+
}
111+
102112
public static <Solution_> SolutionDescriptor<Solution_> buildSolutionDescriptor(Class<Solution_> solutionClass,
103113
Class<?>... entityClasses) {
104114
return buildSolutionDescriptor(solutionClass, Arrays.asList(entityClasses));

core/src/main/java/ai/timefold/solver/core/impl/domain/variable/ShadowVariableUpdateHelper.java

Lines changed: 10 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -52,6 +52,13 @@
5252
*/
5353
public final class ShadowVariableUpdateHelper<Solution_> {
5454

55+
/**
56+
* Deducing the solution descriptors from the solution instance is very expensive.
57+
* Cache the solution descriptors to avoid this,
58+
* even at the unlikely expense of keeping solution descriptors in memory for solutions which are no longer used.
59+
*/
60+
private static final IdentityHashMap<Class<?>, SolutionDescriptor<?>> SOLUTION_DESCRIPTOR_CACHE = new IdentityHashMap<>();
61+
5562
private static final EnumSet<ShadowVariableType> SUPPORTED_TYPES =
5663
EnumSet.of(BASIC, CUSTOM_LISTENER, CASCADING_UPDATE, DECLARATIVE);
5764

@@ -74,21 +81,12 @@ private ShadowVariableUpdateHelper(EnumSet<ShadowVariableType> supportedShadowVa
7481

7582
@SuppressWarnings("unchecked")
7683
public void updateShadowVariables(Solution_ solution) {
77-
var enabledPreviewFeatures = EnumSet.of(PreviewFeature.DECLARATIVE_SHADOW_VARIABLES);
78-
var solutionClass = (Class<Solution_>) solution.getClass();
79-
var initialSolutionDescriptor = SolutionDescriptor.buildSolutionDescriptor(
80-
enabledPreviewFeatures, solutionClass);
81-
var entityClassArray = initialSolutionDescriptor.getAllEntitiesAndProblemFacts(solution)
82-
.stream()
83-
.map(Object::getClass)
84-
.distinct()
85-
.toArray(Class[]::new);
86-
var solutionDescriptor = SolutionDescriptor.buildSolutionDescriptor(enabledPreviewFeatures, solutionClass,
87-
entityClassArray);
84+
var solutionDescriptor = (SolutionDescriptor<Solution_>) SOLUTION_DESCRIPTOR_CACHE.computeIfAbsent(solution.getClass(),
85+
solutionClass -> SolutionDescriptor.buildSolutionDescriptorFromSolution(solution));
8886
try (var scoreDirector = new InternalScoreDirector<>(solutionDescriptor)) {
8987
// When we have a solution, we can reuse the logic from VariableListenerSupport to update all variable types
9088
scoreDirector.setWorkingSolution(solution);
91-
scoreDirector.forceTriggerVariableListeners();
89+
scoreDirector.forceTriggerVariableListeners(true);
9290
}
9391
}
9492

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

Lines changed: 48 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
import static java.util.Objects.requireNonNull;
44

5+
import java.util.Collection;
56
import java.util.Collections;
67
import java.util.IdentityHashMap;
78
import java.util.LinkedHashSet;
@@ -27,6 +28,7 @@
2728
import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply;
2829
import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor;
2930
import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor;
31+
import ai.timefold.solver.core.impl.domain.variable.inverserelation.InverseRelationShadowVariableDescriptor;
3032
import ai.timefold.solver.core.impl.domain.variable.listener.support.VariableListenerSupport;
3133
import ai.timefold.solver.core.impl.domain.variable.listener.support.violation.SolutionTracker;
3234
import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager;
@@ -358,8 +360,52 @@ protected void clearVariableListenerEvents() {
358360
}
359361

360362
@Override
361-
public void forceTriggerVariableListeners() {
362-
variableListenerSupport.forceTriggerAllVariableListeners(getWorkingSolution());
363+
public void forceTriggerVariableListeners(boolean resetShadowVariablesFirst) {
364+
var solution = getWorkingSolution();
365+
if (resetShadowVariablesFirst) {
366+
var solutionDescriptor = getSolutionDescriptor();
367+
solutionDescriptor.visitAllEntities(solution, entity -> {
368+
var entityDescriptor = solutionDescriptor.findEntityDescriptor(entity.getClass());
369+
var shadowVariableDescriptors = entityDescriptor.getShadowVariableDescriptors();
370+
if (shadowVariableDescriptors.isEmpty()) {
371+
return;
372+
}
373+
for (var shadowVariableDescriptor : shadowVariableDescriptors) {
374+
var value = shadowVariableDescriptor.getValue(entity);
375+
if (value == null) {
376+
continue;
377+
}
378+
if (shadowVariableDescriptor instanceof InverseRelationShadowVariableDescriptor
379+
&& value instanceof Collection<?> collection) {
380+
// Inverse collection must never be null, so just empty it.
381+
collection.clear();
382+
continue;
383+
}
384+
var propertyType = shadowVariableDescriptor.getVariablePropertyType();
385+
if (propertyType.isPrimitive()) {
386+
if (propertyType == boolean.class) {
387+
shadowVariableDescriptor.setValue(entity, false);
388+
} else if (propertyType == byte.class) {
389+
shadowVariableDescriptor.setValue(entity, (byte) 0);
390+
} else if (propertyType == char.class) {
391+
shadowVariableDescriptor.setValue(entity, '\u0000');
392+
} else if (propertyType == short.class) {
393+
shadowVariableDescriptor.setValue(entity, (short) 0);
394+
} else if (propertyType == int.class) {
395+
shadowVariableDescriptor.setValue(entity, 0);
396+
} else if (propertyType == long.class) {
397+
shadowVariableDescriptor.setValue(entity, 0L);
398+
} else {
399+
throw new IllegalStateException(
400+
"Impossible state: unknown primitive type %s".formatted(propertyType));
401+
}
402+
} else {
403+
shadowVariableDescriptor.setValue(entity, null);
404+
}
405+
}
406+
});
407+
}
408+
variableListenerSupport.forceTriggerAllVariableListeners(solution);
363409
}
364410

365411
protected void setCalculatedScore(Score_ score) {

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

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -349,8 +349,11 @@ void assertExpectedUndoMoveScore(Move<Solution_> move, InnerScore<Score_> before
349349
* Unlike {@link #triggerVariableListeners()} which only triggers notifications already in the queue,
350350
* this triggers every variable listener on every genuine variable.
351351
* This is useful in {@link SolutionManager#update(Object)} to fill in shadow variable values.
352+
*
353+
* @param resetShadowVariablesFirst true if the shadow variables should be reset first,
354+
* slowing down the operation but preventing stale dependencies.
352355
*/
353-
void forceTriggerVariableListeners();
356+
void forceTriggerVariableListeners(boolean resetShadowVariablesFirst);
354357

355358
/**
356359
* A derived score director is created from a root score director.

core/src/main/java/ai/timefold/solver/core/impl/solver/DefaultSolutionManager.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,7 @@ private <Result_> Result_ callScoreDirector(Solution_ solution,
7878
Maybe use Constraint Streams instead of Easy or Incremental score calculator?""");
7979
}
8080
if (isShadowVariableUpdateEnabled) {
81-
scoreDirector.forceTriggerVariableListeners();
81+
scoreDirector.forceTriggerVariableListeners(solutionUpdatePolicy.isShadowVariableUpdateForced());
8282
}
8383
if (solutionUpdatePolicy.isScoreUpdateEnabled()) {
8484
scoreDirector.calculateScore();

core/src/test/java/ai/timefold/solver/core/impl/domain/variable/ListVariableListenerTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -122,7 +122,7 @@ void addAndRemoveEntity() {
122122
var ann = new TestdataListEntityWithShadowHistory("Ann", a, b, c);
123123

124124
scoreDirector.setWorkingSolution(buildSolution(ann));
125-
scoreDirector.forceTriggerVariableListeners();
125+
scoreDirector.forceTriggerVariableListeners(false);
126126

127127
// Assert inverse entity.
128128
assertEntityHistory(a, ann);

core/src/test/java/ai/timefold/solver/core/impl/domain/variable/custom/CustomVariableListenerTest.java

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -185,7 +185,7 @@ void manyToManyRequiresUniqueEntityEvents() {
185185
solution.setEntityList(new ArrayList<>());
186186
solution.setValueList(List.of(val1));
187187
scoreDirector.setWorkingSolution(solution);
188-
scoreDirector.forceTriggerVariableListeners();
188+
scoreDirector.forceTriggerVariableListeners(true);
189189

190190
scoreDirector.beforeEntityAdded(b);
191191
scoreDirector.getWorkingSolution().getEntityList().add(b);

test/src/main/java/ai/timefold/solver/test/impl/score/stream/DefaultShadowVariableAwareMultiConstraintAssertion.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66

77
import ai.timefold.solver.core.api.score.Score;
88
import ai.timefold.solver.core.api.score.stream.ConstraintProvider;
9+
import ai.timefold.solver.core.api.solver.SolutionManager;
910
import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy;
1011
import ai.timefold.solver.core.impl.score.stream.common.AbstractConstraintStreamScoreDirectorFactory;
1112
import ai.timefold.solver.test.api.score.stream.MultiConstraintAssertion;
@@ -28,11 +29,11 @@ public final class DefaultShadowVariableAwareMultiConstraintAssertion<Solution_,
2829
@Override
2930
public MultiConstraintAssertion settingAllShadowVariables() {
3031
// Most score directors don't need derived status; CS will override this.
32+
SolutionManager.updateShadowVariables(solution);
3133
try (var scoreDirector = scoreDirectorFactory.createScoreDirectorBuilder()
3234
.withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED)
3335
.buildDerived()) {
3436
scoreDirector.setWorkingSolution(solution);
35-
scoreDirector.forceTriggerVariableListeners();
3637
update(scoreDirector.calculateScore(), scoreDirector.getConstraintMatchTotalMap(),
3738
scoreDirector.getIndictmentMap());
3839
toggleInitialized();

test/src/main/java/ai/timefold/solver/test/impl/score/stream/DefaultShadowVariableAwareSingleConstraintAssertion.java

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@
33
import java.util.Objects;
44

55
import ai.timefold.solver.core.api.score.Score;
6+
import ai.timefold.solver.core.api.solver.SolutionManager;
67
import ai.timefold.solver.core.impl.score.constraint.ConstraintMatchPolicy;
78
import ai.timefold.solver.core.impl.score.stream.common.AbstractConstraintStreamScoreDirectorFactory;
89
import ai.timefold.solver.test.api.score.stream.ShadowVariableAwareSingleConstraintAssertion;
@@ -25,11 +26,11 @@ public final class DefaultShadowVariableAwareSingleConstraintAssertion<Solution_
2526
@Override
2627
public SingleConstraintAssertion settingAllShadowVariables() {
2728
// Most score directors don't need derived status; CS will override this.
29+
SolutionManager.updateShadowVariables(solution);
2830
try (var scoreDirector = scoreDirectorFactory.createScoreDirectorBuilder()
2931
.withConstraintMatchPolicy(ConstraintMatchPolicy.ENABLED)
3032
.buildDerived()) {
3133
scoreDirector.setWorkingSolution(solution);
32-
scoreDirector.forceTriggerVariableListeners();
3334
update(scoreDirector.calculateScore(), scoreDirector.getConstraintMatchTotalMap(),
3435
scoreDirector.getIndictmentMap());
3536
toggleInitialized();

0 commit comments

Comments
 (0)