Skip to content

Commit 1356100

Browse files
Christopher-Chianellitriceo
authored andcommitted
feat: fail fast if @ShadowVariableLooped is reference from a @Shadowsources
1 parent 4b57022 commit 1356100

File tree

5 files changed

+65
-3
lines changed

5 files changed

+65
-3
lines changed

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

Lines changed: 19 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@
2323
import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel;
2424
import ai.timefold.solver.core.preview.api.domain.metamodel.VariableMetaModel;
2525
import ai.timefold.solver.core.preview.api.domain.variable.declarative.ShadowSources;
26+
import ai.timefold.solver.core.preview.api.domain.variable.declarative.ShadowVariableLooped;
2627

2728
import org.jspecify.annotations.NonNull;
2829
import org.jspecify.annotations.NullMarked;
@@ -132,10 +133,12 @@ public static <Entity_, Value_> RootVariableSource<Entity_, Value_> from(
132133
factCountSinceLastVariable = 0;
133134

134135
if (parentVariableType == null) {
135-
parentVariableType = determineParentVariableType(chainToVariable, memberAccessor);
136+
parentVariableType =
137+
determineParentVariableType(rootEntityClass, variablePath, chainToVariable, memberAccessor);
136138
}
137139
if (hasListMemberAccessor && groupParentVariableType == null) {
138-
groupParentVariableType = determineParentVariableType(chainToVariable, memberAccessor);
140+
groupParentVariableType =
141+
determineParentVariableType(rootEntityClass, variablePath, chainToVariable, memberAccessor);
139142
}
140143
} else {
141144
factCountSinceLastVariable++;
@@ -350,7 +353,8 @@ public static boolean isVariable(PlanningSolutionMetaModel<?> metaModel, Class<?
350353
return metaModel.entity(declaringClass).hasVariable(memberName);
351354
}
352355

353-
private static ParentVariableType determineParentVariableType(List<MemberAccessor> chain, MemberAccessor memberAccessor) {
356+
private static ParentVariableType determineParentVariableType(Class<?> rootClass, String variablePath,
357+
List<MemberAccessor> chain, MemberAccessor memberAccessor) {
354358
var isIndirect = chain.size() > 1;
355359
var declaringClass = memberAccessor.getDeclaringClass();
356360
var memberName = memberAccessor.getName();
@@ -384,6 +388,18 @@ private static ParentVariableType determineParentVariableType(List<MemberAccesso
384388
if (getAnnotation(declaringClass, memberName, PlanningVariable.class) != null) {
385389
return ParentVariableType.VARIABLE;
386390
}
391+
if (getAnnotation(declaringClass, memberName, ShadowVariableLooped.class) != null) {
392+
throw new IllegalArgumentException("""
393+
The source path (%s) starting from root class (%s) accesses a @%s property (%s).
394+
@%s properties cannot be used as a source, since they are not guaranteed to
395+
be updated when the supplier is called. Supplier methods are only called when
396+
none of their dependencies are looped, so reading @%s properties are not needed.
397+
Maybe remove the source path (%s) from the @%s?
398+
""".formatted(
399+
variablePath, rootClass.getCanonicalName(), ShadowVariableLooped.class.getSimpleName(),
400+
memberName, ShadowVariableLooped.class.getSimpleName(), ShadowVariableLooped.class.getSimpleName(),
401+
variablePath, ShadowSources.class.getSimpleName()));
402+
}
387403
return ParentVariableType.NO_PARENT;
388404
}
389405

core/src/main/java/ai/timefold/solver/core/preview/api/domain/variable/declarative/ShadowVariableLooped.java

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,14 @@
3232
* Should be used in a filter for a hard {@link Constraint} to penalize
3333
* looped entities, since {@link PlanningSolution} with looped entities are
3434
* typically not valid.
35+
* <p>
36+
* Important:
37+
* Do not use a {@link ShadowVariableLooped} property in a method annotated
38+
* with {@link ShadowSources}. {@link ShadowVariableLooped} properties can
39+
* be updated after the {@link ShadowSources} marked method is called, causing
40+
* score corruption. {@link ShadowSources} marked methods do not need to check
41+
* {@link ShadowVariableLooped} properties, since they are only called if all
42+
* their dependencies are not looped.
3543
*/
3644
@Target({ METHOD, FIELD })
3745
@Retention(RUNTIME)

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

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@
1919
import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningEntityMetaModel;
2020
import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningSolutionMetaModel;
2121
import ai.timefold.solver.core.preview.api.domain.metamodel.ShadowVariableMetaModel;
22+
import ai.timefold.solver.core.preview.api.domain.variable.declarative.ShadowSources;
23+
import ai.timefold.solver.core.preview.api.domain.variable.declarative.ShadowVariableLooped;
2224
import ai.timefold.solver.core.testdomain.TestdataEntity;
2325
import ai.timefold.solver.core.testdomain.TestdataObject;
2426
import ai.timefold.solver.core.testdomain.TestdataSolution;
@@ -575,6 +577,23 @@ record RootClass(TestClass inner) {
575577
"that does not exist.");
576578
}
577579

580+
@Test
581+
void errorIfShadowVariableLoopedReferenced() {
582+
assertThatCode(() -> RootVariableSource.from(
583+
planningSolutionMetaModel,
584+
TestdataInvalidDeclarativeValue.class,
585+
"shadow",
586+
"isLooped",
587+
DEFAULT_MEMBER_ACCESSOR_FACTORY,
588+
DEFAULT_DESCRIPTOR_POLICY))
589+
.isInstanceOf(IllegalArgumentException.class)
590+
.hasMessageContainingAll("The source path (isLooped)",
591+
"starting from root class (" + TestdataInvalidDeclarativeValue.class.getCanonicalName() + ")",
592+
"accesses a @" + ShadowVariableLooped.class.getSimpleName() + " property",
593+
"(isLooped)",
594+
"Maybe remove the source path (isLooped) from the @" + ShadowSources.class.getSimpleName());
595+
}
596+
578597
@Test
579598
void isVariableIsTrueForVariableOnEntity() {
580599
var metaModel = SolutionDescriptor.buildSolutionDescriptor(TestdataSolution.class, TestdataEntity.class).getMetaModel();

core/src/test/java/ai/timefold/solver/core/testdomain/declarative/invalid/TestdataInvalidDeclarativeValue.java

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import ai.timefold.solver.core.api.domain.variable.PreviousElementShadowVariable;
77
import ai.timefold.solver.core.api.domain.variable.ShadowVariable;
88
import ai.timefold.solver.core.preview.api.domain.variable.declarative.ShadowSources;
9+
import ai.timefold.solver.core.preview.api.domain.variable.declarative.ShadowVariableLooped;
910
import ai.timefold.solver.core.testdomain.TestdataObject;
1011

1112
@PlanningEntity
@@ -23,6 +24,9 @@ public class TestdataInvalidDeclarativeValue extends TestdataObject {
2324
@ShadowVariable(supplierName = "shadowSupplier")
2425
TestdataInvalidDeclarativeValue shadow;
2526

27+
@ShadowVariableLooped
28+
boolean isLooped;
29+
2630
public TestdataInvalidDeclarativeValue() {
2731
}
2832

@@ -70,6 +74,14 @@ public void setShadow(TestdataInvalidDeclarativeValue shadow) {
7074
this.shadow = shadow;
7175
}
7276

77+
public boolean isLooped() {
78+
return isLooped;
79+
}
80+
81+
public void setLooped(boolean looped) {
82+
isLooped = looped;
83+
}
84+
7385
@ShadowSources("previous")
7486
public TestdataInvalidDeclarativeValue dependencySupplier() {
7587
return previous;

docs/src/modules/ROOT/pages/using-timefold-solver/modeling-planning-problems.adoc

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1221,6 +1221,13 @@ Python::
12211221
----
12221222
====
12231223
1224+
[IMPORTANT]
1225+
====
1226+
Do not use a @ShadowVariableLooped property in a method annotated with @ShadowSources.
1227+
@ShadowVariableLooped properties can be updated after the @ShadowSources marked method is called, causing score corruption.
1228+
@ShadowSources marked methods do not need to check @ShadowVariableLooped properties, since they are only called if all their dependencies are not looped.
1229+
====
1230+
12241231
=== Shadow variable cloning
12251232
12261233
A shadow variable's value (just like a genuine variable's value)

0 commit comments

Comments
 (0)