Skip to content

Commit f7f251d

Browse files
authored
feat: Testing support for Declarative Shadow Variables (#1571)
1 parent a1567ff commit f7f251d

16 files changed

Lines changed: 1468 additions & 112 deletions

File tree

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

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
import static ai.timefold.solver.core.api.solver.SolutionUpdatePolicy.UPDATE_ALL;
55

66
import java.util.List;
7+
import java.util.Objects;
78
import java.util.UUID;
89
import java.util.function.Function;
910

@@ -15,6 +16,7 @@
1516
import ai.timefold.solver.core.api.score.calculator.EasyScoreCalculator;
1617
import ai.timefold.solver.core.api.score.constraint.ConstraintMatchTotal;
1718
import ai.timefold.solver.core.api.score.constraint.Indictment;
19+
import ai.timefold.solver.core.impl.domain.variable.ShadowVariableUpdateHelper;
1820
import ai.timefold.solver.core.impl.solver.DefaultSolutionManager;
1921
import ai.timefold.solver.core.preview.api.domain.solution.diff.PlanningSolutionDiff;
2022

@@ -80,10 +82,45 @@ public interface SolutionManager<Solution_, Score_ extends Score<Score_>> {
8082
* @param solutionUpdatePolicy if unsure, pick {@link SolutionUpdatePolicy#UPDATE_ALL}
8183
* @return possibly null if already null and {@link SolutionUpdatePolicy} didn't cause its update
8284
* @see SolutionUpdatePolicy Description of individual policies with respect to performance trade-offs.
85+
* @see #updateShadowVariables(Object) Alternative logic that does not require a solver configuration to update shadow
86+
* variables
8387
*/
8488
@Nullable
8589
Score_ update(@NonNull Solution_ solution, @NonNull SolutionUpdatePolicy solutionUpdatePolicy);
8690

91+
/**
92+
* This method updates all shadow variables at the entity level,
93+
* simplifying the requirements of {@link SolutionManager#update(Object)}.
94+
* Unlike the latter method,
95+
* it does not require the complete configuration necessary to obtain an instance of {@link SolutionManager}.
96+
* <p>
97+
* However, this method requires that the entity does not define any shadow variables that rely on listeners,
98+
* as that would require a complete solution.
99+
*
100+
* @param solutionClass the solution class
101+
* @param entities all entities to be updated
102+
*/
103+
static <Solution_> void updateShadowVariables(@NonNull Class<Solution_> solutionClass,
104+
@NonNull Object... entities) {
105+
Objects.requireNonNull(solutionClass);
106+
Objects.requireNonNull(entities);
107+
if (entities.length == 0) {
108+
throw new IllegalArgumentException("The entity array cannot be empty.");
109+
}
110+
ShadowVariableUpdateHelper.<Solution_> create().updateShadowVariables(solutionClass, entities);
111+
}
112+
113+
/**
114+
* Same as {@link #updateShadowVariables(Class, Object...)},
115+
* this method accepts a solution rather than a list of entities.
116+
*
117+
* @param solution the solution
118+
*/
119+
static <Solution_> void updateShadowVariables(@NonNull Solution_ solution) {
120+
Objects.requireNonNull(solution);
121+
ShadowVariableUpdateHelper.<Solution_> create().updateShadowVariables(solution);
122+
}
123+
87124
/**
88125
* As defined by {@link #explain(Object, SolutionUpdatePolicy)},
89126
* using {@link SolutionUpdatePolicy#UPDATE_ALL}.

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

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1231,6 +1231,14 @@ public double getProblemScale(Solution_ solution) {
12311231
return scale;
12321232
}
12331233

1234+
public List<ShadowVariableDescriptor<Solution_>> getAllShadowVariableDescriptors() {
1235+
var out = new ArrayList<ShadowVariableDescriptor<Solution_>>();
1236+
for (var entityDescriptor : entityDescriptorMap.values()) {
1237+
out.addAll(entityDescriptor.getShadowVariableDescriptors());
1238+
}
1239+
return out;
1240+
}
1241+
12341242
public List<DeclarativeShadowVariableDescriptor<Solution_>> getDeclarativeShadowVariableDescriptors() {
12351243
var out = new ArrayList<DeclarativeShadowVariableDescriptor<Solution_>>();
12361244
for (var entityDescriptor : entityDescriptorMap.values()) {

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

Lines changed: 392 additions & 0 deletions
Large diffs are not rendered by default.
Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,8 @@
1+
package ai.timefold.solver.core.impl.domain.variable.listener.support;
2+
3+
public enum ShadowVariableType {
4+
BASIC, // index, inverse element, anchor element, previous element, and next element
5+
CUSTOM_LISTENER,
6+
CASCADING_UPDATE,
7+
DECLARATIVE
8+
}

core/src/main/java/ai/timefold/solver/core/impl/domain/variable/listener/support/VariableListenerSupport.java

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,10 @@
11
package ai.timefold.solver.core.impl.domain.variable.listener.support;
22

3+
import static ai.timefold.solver.core.impl.domain.variable.listener.support.ShadowVariableType.BASIC;
4+
import static ai.timefold.solver.core.impl.domain.variable.listener.support.ShadowVariableType.CASCADING_UPDATE;
5+
import static ai.timefold.solver.core.impl.domain.variable.listener.support.ShadowVariableType.CUSTOM_LISTENER;
6+
import static ai.timefold.solver.core.impl.domain.variable.listener.support.ShadowVariableType.DECLARATIVE;
7+
38
import java.util.ArrayList;
49
import java.util.Collection;
510
import java.util.Collections;
@@ -72,6 +77,7 @@ public static <Solution_> VariableListenerSupport<Solution_> create(InnerScoreDi
7277
private DefaultShadowVariableSession<Solution_> shadowVariableSession = null;
7378
@Nullable
7479
private ListVariableStateSupply<Solution_> listVariableStateSupply = null;
80+
private final List<ShadowVariableType> supportedShadowVariableTypeList;
7581

7682
VariableListenerSupport(InnerScoreDirector<Solution_, ?> scoreDirector, NotifiableRegistry<Solution_> notifiableRegistry,
7783
@NonNull IntFunction<TopologicalOrderGraph> shadowVariableGraphCreator) {
@@ -89,6 +95,14 @@ public static <Solution_> VariableListenerSupport<Solution_> create(InnerScoreDi
8995
this.unassignedValueWithEmptyInverseEntitySet =
9096
hasCascadingUpdates ? new LinkedIdentityHashSet<>() : Collections.emptySet();
9197
this.shadowVariableGraphCreator = shadowVariableGraphCreator;
98+
// Existing dependencies rely on this list
99+
// to ensure consistency in supporting all available shadow variable types
100+
// See ShadowVariableUpdateHelper
101+
this.supportedShadowVariableTypeList = List.of(BASIC, CUSTOM_LISTENER, CASCADING_UPDATE, DECLARATIVE);
102+
}
103+
104+
public List<ShadowVariableType> getSupportedShadowVariableTypes() {
105+
return supportedShadowVariableTypeList;
92106
}
93107

94108
public void linkVariableListeners() {
Lines changed: 154 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,154 @@
1+
package ai.timefold.solver.core.impl.domain.variable;
2+
3+
import static ai.timefold.solver.core.impl.domain.variable.listener.support.ShadowVariableType.BASIC;
4+
import static org.assertj.core.api.Assertions.assertThat;
5+
import static org.assertj.core.api.Assertions.assertThatCode;
6+
7+
import java.time.Duration;
8+
import java.util.List;
9+
10+
import ai.timefold.solver.core.api.solver.SolutionManager;
11+
import ai.timefold.solver.core.testdomain.declarative.basic.TestdataBasicVarEntity;
12+
import ai.timefold.solver.core.testdomain.declarative.basic.TestdataBasicVarSolution;
13+
import ai.timefold.solver.core.testdomain.declarative.basic.TestdataBasicVarValue;
14+
import ai.timefold.solver.core.testdomain.declarative.chained.TestdataChainedVarEntity;
15+
import ai.timefold.solver.core.testdomain.declarative.chained.TestdataChainedVarSolution;
16+
import ai.timefold.solver.core.testdomain.declarative.chained.TestdataChainedVarValue;
17+
import ai.timefold.solver.core.testdomain.shadow.full.TestdataShadowedFullEntity;
18+
import ai.timefold.solver.core.testdomain.shadow.full.TestdataShadowedFullSolution;
19+
import ai.timefold.solver.core.testdomain.shadow.full.TestdataShadowedFullValue;
20+
21+
import org.assertj.core.api.Assertions;
22+
import org.junit.jupiter.api.Test;
23+
24+
class ShadowVariableUpdateTest {
25+
26+
@Test
27+
void emptyEntities() {
28+
Assertions
29+
.assertThatCode(() -> SolutionManager.updateShadowVariables(TestdataShadowedFullSolution.class, new Object[0]))
30+
.hasMessageContaining("The entity array cannot be empty.");
31+
}
32+
33+
@Test
34+
void invalidCustomListener() {
35+
var value = new TestdataShadowedFullValue("v1");
36+
var entity = new TestdataShadowedFullEntity("e1");
37+
var solution = new TestdataShadowedFullSolution();
38+
solution.setEntityList(List.of(entity));
39+
solution.setValueList(List.of(value));
40+
Assertions
41+
.assertThatCode(
42+
() -> SolutionManager.updateShadowVariables(TestdataShadowedFullSolution.class, entity, value))
43+
.hasMessageContaining("Custom shadow variable descriptors are not supported");
44+
}
45+
46+
@Test
47+
void invalidInverseRelation() {
48+
var value1 = new TestdataBasicVarValue("c1", Duration.ZERO);
49+
var entity1 = new TestdataBasicVarEntity("v1", value1);
50+
value1.setEntityList(null);
51+
var solution = new TestdataBasicVarSolution(List.of(entity1), List.of(value1));
52+
Assertions
53+
.assertThatCode(
54+
() -> SolutionManager.updateShadowVariables(TestdataBasicVarSolution.class, entity1, value1))
55+
.hasMessageContaining(
56+
"The entity", "has a variable (value) with value",
57+
"which has a sourceVariableName variable (entityList) which is null.");
58+
Assertions
59+
.assertThatCode(
60+
() -> SolutionManager.updateShadowVariables(solution))
61+
.isInstanceOf(NullPointerException.class);
62+
}
63+
64+
@Test
65+
void unsupportedShadowVariableType() {
66+
var shadowVariableHelper = ShadowVariableUpdateHelper.<TestdataBasicVarSolution> create(BASIC);
67+
var value1 = new TestdataBasicVarValue("v1", Duration.ofSeconds(10));
68+
var entity1 = new TestdataBasicVarEntity("e1", value1);
69+
assertThatCode(() -> shadowVariableHelper.updateShadowVariables(TestdataBasicVarSolution.class, entity1, value1))
70+
.hasMessageContaining(
71+
"The following shadow variable types are not currently supported ([CUSTOM_LISTENER, CASCADING_UPDATE, DECLARATIVE])");
72+
}
73+
74+
@Test
75+
void updateBasicShadowVariables() {
76+
var value1 = new TestdataBasicVarValue("v1", Duration.ofSeconds(10));
77+
var value2 = new TestdataBasicVarValue("v2", Duration.ofSeconds(20));
78+
var entity1 = new TestdataBasicVarEntity("e1", value1);
79+
var entity2 = new TestdataBasicVarEntity("e2", value2);
80+
var entity3 = new TestdataBasicVarEntity("e3", value1);
81+
SolutionManager.updateShadowVariables(TestdataBasicVarSolution.class, entity1, entity2, entity3, value1, value2);
82+
assertThat(value1.getEntityList()).containsExactly(entity1, entity3);
83+
assertThat(value2.getEntityList()).containsExactly(entity2);
84+
assertThat(value1.getStartTime()).isEqualTo(TestdataBasicVarValue.DEFAULT_TIME.plusDays(value1.getEntityList().size()));
85+
assertThat(value1.getEndTime()).isEqualTo(value1.getStartTime().plus(value1.getDuration()));
86+
assertThat(value2.getStartTime()).isEqualTo(TestdataBasicVarValue.DEFAULT_TIME.plusDays(value2.getEntityList().size()));
87+
assertThat(value2.getEndTime()).isEqualTo(value2.getStartTime().plus(value2.getDuration()));
88+
}
89+
90+
@Test
91+
void updateBasicShadowVariablesOnlyPlanningEntity() {
92+
var value1 = new TestdataBasicVarValue("v1", Duration.ofDays(10));
93+
var value2 = new TestdataBasicVarValue("v2", Duration.ofDays(20));
94+
var entity1 = new TestdataBasicVarEntity("e1", value1);
95+
var entity2 = new TestdataBasicVarEntity("e2", value2);
96+
SolutionManager.updateShadowVariables(TestdataBasicVarSolution.class, entity1, entity2);
97+
assertThat(entity1.getDurationInDays()).isEqualTo(value1.getDuration().toDays());
98+
assertThat(entity2.getDurationInDays()).isEqualTo(value2.getDuration().toDays());
99+
}
100+
101+
@Test
102+
void solutionUpdateBasicShadowVariables() {
103+
var value1 = new TestdataBasicVarValue("v1", Duration.ofSeconds(10));
104+
var value2 = new TestdataBasicVarValue("v2", Duration.ofSeconds(20));
105+
var entity1 = new TestdataBasicVarEntity("e1", value1);
106+
var entity2 = new TestdataBasicVarEntity("e2", value2);
107+
var entity3 = new TestdataBasicVarEntity("e3", value1);
108+
var solution = new TestdataBasicVarSolution();
109+
solution.setEntities(List.of(entity1, entity2, entity3));
110+
solution.setValues(List.of(value1, value2));
111+
SolutionManager.updateShadowVariables(solution);
112+
assertThat(value1.getEntityList()).containsExactly(entity1, entity3);
113+
assertThat(value2.getEntityList()).containsExactly(entity2);
114+
assertThat(value1.getStartTime()).isEqualTo(TestdataBasicVarValue.DEFAULT_TIME.plusDays(value1.getEntityList().size()));
115+
assertThat(value1.getEndTime()).isEqualTo(value1.getStartTime().plus(value1.getDuration()));
116+
assertThat(value2.getStartTime()).isEqualTo(TestdataBasicVarValue.DEFAULT_TIME.plusDays(value2.getEntityList().size()));
117+
assertThat(value2.getEndTime()).isEqualTo(value2.getStartTime().plus(value2.getDuration()));
118+
}
119+
120+
@Test
121+
void updateChainedShadowVariables() {
122+
var value1 = new TestdataChainedVarValue("v1", Duration.ofDays(10));
123+
var value2 = new TestdataChainedVarValue("v2", Duration.ofDays(20));
124+
var entity1 = new TestdataChainedVarEntity("e1", value1);
125+
var entity2 = new TestdataChainedVarEntity("e2", value2);
126+
SolutionManager.updateShadowVariables(TestdataChainedVarSolution.class, entity1, entity2, value1, value2);
127+
assertThat(value1.getNext()).isSameAs(entity1);
128+
assertThat(value2.getNext()).isSameAs(entity2);
129+
assertThat(value1.getStartTime()).isEqualTo(TestdataBasicVarValue.DEFAULT_TIME.plusDays(1));
130+
assertThat(value1.getEndTime()).isEqualTo(value1.getStartTime().plus(value1.getDuration()));
131+
assertThat(value2.getStartTime()).isEqualTo(TestdataBasicVarValue.DEFAULT_TIME.plusDays(1));
132+
assertThat(value2.getEndTime()).isEqualTo(value2.getStartTime().plus(value2.getDuration()));
133+
assertThat(entity1.getDurationInDays()).isEqualTo(value1.getDuration().toDays());
134+
assertThat(entity2.getDurationInDays()).isEqualTo(value2.getDuration().toDays());
135+
}
136+
137+
@Test
138+
void solutionUpdateChainedShadowVariables() {
139+
var value1 = new TestdataChainedVarValue("v1", Duration.ofSeconds(10));
140+
var value2 = new TestdataChainedVarValue("v2", Duration.ofSeconds(20));
141+
var entity1 = new TestdataChainedVarEntity("e1", value1);
142+
var entity2 = new TestdataChainedVarEntity("e2", value2);
143+
var solution = new TestdataChainedVarSolution();
144+
solution.setEntities(List.of(entity1, entity2));
145+
solution.setValues(List.of(value1, value2));
146+
SolutionManager.updateShadowVariables(solution);
147+
assertThat(value1.getNext()).isSameAs(entity1);
148+
assertThat(value2.getNext()).isSameAs(entity2);
149+
assertThat(value1.getStartTime()).isEqualTo(TestdataBasicVarValue.DEFAULT_TIME.plusDays(1));
150+
assertThat(value1.getEndTime()).isEqualTo(value1.getStartTime().plus(value1.getDuration()));
151+
assertThat(value2.getStartTime()).isEqualTo(TestdataBasicVarValue.DEFAULT_TIME.plusDays(1));
152+
assertThat(value2.getEndTime()).isEqualTo(value2.getStartTime().plus(value2.getDuration()));
153+
}
154+
}

0 commit comments

Comments
 (0)