-
Notifications
You must be signed in to change notification settings - Fork 121
feat: Declarative Shadow Variables #1421
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
feat: Declarative Shadow Variables #1421
Conversation
e97e05d
to
f478a44
Compare
Before I do a proper review, I suggest we do some house-keeping first. (Otherwise comments would get lost when files are being renamed and moved around.)
|
core/src/main/java/ai/timefold/solver/core/impl/domain/entity/descriptor/EntityDescriptor.java
Outdated
Show resolved
Hide resolved
@@ -34,7 +49,9 @@ | |||
public final class VariableListenerSupport<Solution_> implements SupplyManager { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We need to be careful here. This feature should have no performance impact when disabled. I'll run some numbers to confirm that.
...in/java/ai/timefold/solver/core/impl/domain/variable/provided/AbstractVariableReference.java
Outdated
Show resolved
Hide resolved
...main/java/ai/timefold/solver/core/impl/domain/variable/provided/ChangedVariableNotifier.java
Outdated
Show resolved
Hide resolved
...main/java/ai/timefold/solver/core/impl/domain/variable/provided/ChangedVariableNotifier.java
Outdated
Show resolved
Hide resolved
...ava/ai/timefold/solver/core/impl/domain/variable/provided/DefaultGroupVariableReference.java
Outdated
Show resolved
Hide resolved
...a/ai/timefold/solver/core/impl/domain/variable/provided/DefaultShadowCalculationBuilder.java
Outdated
Show resolved
Hide resolved
...i/timefold/solver/core/impl/domain/variable/provided/InvalidityMarkerVariableDescriptor.java
Outdated
Show resolved
Hide resolved
.../main/java/ai/timefold/solver/core/preview/api/variable/provided/GroupVariableReference.java
Outdated
Show resolved
Hide resolved
@@ -0,0 +1,13 @@ | |||
package ai.timefold.solver.core.preview.api.variable.provided; | |||
|
|||
public interface ShadowVariableSession { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
My understanding of this API is still limited, but do we actually need this public API anywhere in the solver? IMO this is internal in core, and only exposed in the tests; therefore I'd make this public in the tests, not in the solver itself.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
It can be moved; it is here for convenience when testing the prototype. (core internally depends on the default implementation which has before/after methods).
import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor; | ||
import ai.timefold.solver.core.impl.domain.variable.provided.MockShadowVariableSessionFactory; | ||
|
||
public interface ShadowVariableSessionFactory { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Dtto.
Hypothetical scenario:
Do they all become part of the same graph? In other words - is the graph limited to one shadow provider, or is it built from all shadow providers combined, and then split into individual independent components? |
Although untested, it is built from all shadow providers combined, and then split into individual independent components. Defining the same shadow variable twice is an error though; (you can use it multiple time, but cannot declare it multiple times). |
Please add this to the list of things that need to be tested. |
...n/java/ai/timefold/solver/core/preview/api/domain/variable/declarative/InvalidityMarker.java
Show resolved
Hide resolved
import org.jspecify.annotations.Nullable; | ||
|
||
@NullMarked | ||
public interface ShadowVariableSession { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This seems similar to ConstraintVerifier, so the naming should be similar.
Does the ConstraintVerifier API have anything that is a Session?
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As far as I understand, this does not need to be in the public API. Please remove.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
First round of review, surface-level.
Next round will look deeper into the logic.
@@ -21,6 +22,19 @@ | |||
@Retention(RUNTIME) | |||
@Repeatable(List.class) | |||
public @interface ShadowVariable { | |||
/** | |||
* Experimental. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
We call that "preview feature".
* @return sometimes null | ||
*/ | ||
public static Method getDeclaredGetterMethod(Class<?> containingClass, String propertyName) { | ||
String capitalizedPropertyName = capitalizePropertyName(propertyName); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please write all new code using var
, unless it'd lead to code that's worse than the alternative. (Typically if it would require nasty casting etc.)
import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor; | ||
import ai.timefold.solver.core.impl.score.director.InnerScoreDirector; | ||
|
||
public class ChangedVariableNotifier<Solution_> { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Arguably can be a record, eliminating some boilerplate.
before/after biconsumer can be exposed directly, avoiding indirection.
// var entityClass = variableDescriptor.getEntityDescriptor().getEntityClass(); | ||
// graph.afterVariableChanged(new VariableId(entityClass, variableDescriptor.getVariableName()), | ||
// entity); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please remove.
import org.jspecify.annotations.Nullable; | ||
|
||
@NullMarked | ||
public class MockShadowVariableSession<Solution_> implements ShadowVariableSession { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If I understand this correctly, this serves for testing of the shadow variables. As such, shouldn't this be moved to timefold-solver-test
, together with constraint verifier et al.? If it only serves for our own internal purposes, then it should arguably be in test
and not main
.
import org.jspecify.annotations.Nullable; | ||
|
||
@NullMarked | ||
public interface ShadowVariableSession { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
As far as I understand, this does not need to be in the public API. Please remove.
import org.jspecify.annotations.NullMarked; | ||
|
||
@NullMarked | ||
public interface ShadowVariableSessionFactory { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Likewise, please remove from public API. The user has no use for this. Maybe this needs to exist for testing, in which case I'd move it to timefold-solver-test
. Better yet, let's just hide it, because we have not yet discussed testing of these new shadow variables at all.
|
||
@Target(ElementType.METHOD) | ||
@Retention(RetentionPolicy.RUNTIME) | ||
public @interface ShadowVariableUpdater { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
TODO: Naming and Javadoc.
Arguably, this is not an "updater". It doesn't update the variable. It's a function that computes its new value, but it doesn't set it.
Calculator? Computer? Value Function? Value Provider? ...
|
||
import org.jspecify.annotations.NonNull; | ||
|
||
public class TestdataFSRAssertionConstraintProvider implements ConstraintProvider { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Please remove references to FSR. FSR has no meaning in the context of the solver.
|
||
import org.jspecify.annotations.NonNull; | ||
|
||
public class TestdataTAConstraintProvider implements ConstraintProvider { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Just as with "FSR", "TA" is also meaningless here. The solver isn't tailored to any particular problem, and these domain classes should therefore carry generic names. We already have dozens of them, and we were able to follow this policy; I don't see the need to divert now.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
General comments having read the entire PR in depth:
- The code structure could be improved. There are several very long methods, which (when broken down into smaller, well-named ones) will help the algorithms to be self-documenting.
- The public API of the feature is undocumented.
- The testing infrastructure for the new mechanism should be hidden for now; we haven't discussed it at all, and we should not expose it to the users since we only have a limited amount of time to get this merged.
- The tests are complex enough to warrant comments.
We need to go through a naming excercise for the two new annotations we expose. Also, the feature name itself ("declarative shadow variables") is arguably no longer accurate. Let's leave that for the end, once everything is properly documented, at which point we'll have the most accurate information to infer correct names for concepts.
@@ -2603,6 +2603,9 @@ | |||
<xs:restriction base="xs:string"> | |||
|
|||
|
|||
<xs:enumeration value="DECLARATIVE_SHADOW_VARIABLES"/> |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This approach to shadow variables can IMO no longer be called "declarative". It used to be declarative, when all the handles etc. had to be declared. Now it's just a better variable listener. We need a new name for the feature.
ShadowVariableDescriptor<Solution_> variableDescriptor; | ||
var annotation = memberAccessor.getAnnotation(ShadowVariable.class); | ||
if (annotation != null && !annotation.method().isEmpty()) { | ||
variableDescriptor = |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Where are the fail-fasts for when the other fields are used? The Javadoc suggests that they exist, but I didn't notice them.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Fail fasts are in RootVariableSource, which does the processing of the variable paths.
public class DefaultShadowVariableSession<Solution_> implements ShadowVariableSession, Supply { | ||
final VariableReferenceGraph<Solution_> graph; | ||
|
||
record EntityVariablePair<Solution_>(VariableDescriptor<Solution_> variableDescriptor, Object entity) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Considering what the descriptor is used for, it can easily be replaced by VariableMetaModel.
}); | ||
} | ||
// Note: it is impossible to have a declarative variable affect graph edges, | ||
// since accessing a declarative variable from another declarative variable is prohibited. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
"Accessing a declarative variable from another declarative variable" clearly doesn't mean what I think it means, because accessing one variable for another is the goal of this entire "a.b" syntax.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The older version the PR was not tested with more exotic cases, such as "previous.previous" or "factProperty.previous" or "myShadowVariable.myOtherShadowVariable".
The old impl made it harder to see the inherent issues that this impl makes obvious by removing all the indirection.
Basically, the three rules are:
- You can only access normal shadow variables from the root entity or from a collection element. Otherwise, the variable listener need to maintain an inverse set to lookup the affected entities for a shadow variable change.
- You cannot chain multiple declarative shadow variables together in a single path. For instance, "closestStation.visitCount"; this has the potential to change the dependency/topology of the graph in the middle of an update.
- Your path must have at least one variable in it, and all names must be the name of members in the class.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Two comments:
-
Let's make sure these rules are listed in a Javadoc somewhere. Lately we've been touching decade-old code, and we often wished the authors of that code had the foresight to list all of their thinking, rules and assumptions somewhere. Code archeology is not fun, and in some of my more recent PRs, I have also started adding significantly more comments than in the past. Not so much about behavior (behavior is often obvious), but about motivation, intentions and circumstances.
-
"Your path must have at least one variable in it, and all names must be the name of members in the class." What else could be there in the path? I'd argue nothing but variables. Constants do not need to be referenced - we only need to reference what we need to build and maintain the graph.
for (var declarativeShadowVariable : declarativeShadowVariableDescriptors) { | ||
var fromEntityClass = declarativeShadowVariable.getEntityDescriptor().getEntityClass(); | ||
var fromVariableName = declarativeShadowVariable.getVariableName(); | ||
final var fromVariableId = new VariableId(fromEntityClass, fromVariableName); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
VariableMetaModel was invented earlier to serve this purpose.
(VariableDescriptor is more of a utility class, and it is a mess. VariableMetaModel is a well-defined carrier of metadata.)
} | ||
|
||
Iterable<Object> iterable = (Iterable<Object>) current; | ||
outer: for (var item : iterable) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Can we do this without labels? My experience has been that, whenever I felt like I need to use explicit goto, there was a better code structure that avoided it.
// Variable state is probably incorrect/will trigger a different | ||
// exception, so return early. | ||
return; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
If an uncaught exception occurred, why are we even here? The solver would've crashed before getting here. (The undo move issue was already fixed.)
return; | ||
} | ||
|
||
record AffectedEntity(Object entity, VariableUpdaterInfo variableUpdaterInfo) { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The method is long enough already, even without data types being declared inside of it. Please move this type outside - you can still make it private.
import org.junit.jupiter.api.Test; | ||
import org.mockito.Mockito; | ||
|
||
public class FSRShadowVariableTest { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These tests are complex enough that they deserve comments.
What behavior is being tested by each group of lines, why is the expected result what it is.
|
||
import org.junit.jupiter.api.Test; | ||
|
||
public class TAShadowVariableTest { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's not overdo it with the tests that run the solver.
I'm fine with having this one, it's a good end-to-end test, but let's test future functionality on a much more granular level.
@@ -19,14 +18,14 @@ record EntityVariablePair<Solution_>(VariableDescriptor<Solution_> variableDescr | |||
@Override | |||
public boolean equals(@Nullable Object o) { | |||
if (o instanceof EntityVariablePair<?> other) { | |||
return entity == other.entity && Objects.equals(variableDescriptor, other.variableDescriptor); | |||
return entity == other.entity && variableDescriptor.getOrdinal() == other.variableDescriptor.getOrdinal(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Variable descriptors should not equal one another, each should be a unique instance. Therefore == ought to be enough here...
} | ||
return false; | ||
} | ||
|
||
@Override | ||
public int hashCode() { | ||
return Objects.hash(System.identityHashCode(entity), variableDescriptor); | ||
return (31 * System.identityHashCode(entity)) ^ variableDescriptor.getOrdinal(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
... but ordinal has the same function. In either case, arguably we should put this logic inside VariableDescriptor
's equals/hashCode and not externalize it here.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
And maybe I would add a comment here that this class is often used as key in hashmaps, and therefore this method needs to be optimized.
changed.set(node.id()); | ||
} | ||
|
||
public void updateChanged() { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
This method is very long and also - not coincidentally - shows up as a dominator in the flamegraph. Please break this method down into smaller methods, so that we can better understand the logic, and so that the flame graph can actually meaningfully show how much each part contributes to the result.
Provided shadow variables work by calculating the topological order of each shadow variable. The nodes in the graph are paths to each shadow variable, binded to a particular entity instance. - The path `e1:Entity.#id.a` is the source path for the shadow variable `a` on entity e1 - If `e2.previous = e1`, then the path `e2:Entity.#id.#previous.a` is an alias path for the shadow variable `a` on e1. - The path can have multiple parts; like `e1:Entity.#id.#previous.#previous.a`. In this case, `e1:Entity.#id.#previous` is the parent of `e1:Entity.#id.#previous.#previous`. The edges in the graph are the dependencies for each shadow variable. - There is a fixed edge from the parent to each of its children. (i.e. `e1:Entity.#id.#previous` -> `e1:Entity.#id.#previous.a`) - There is a fixed edge from the direct dependencies of a shadow variable to the shadow variable. (i.e. `e1:Entity.#id.#previous.readyTime` -> `e1:Entity.#id.#startTime`) - There is a dynamic edge from each shadow variable to all its aliases. (i.e. `e1:Entity.#id.startTime` -> `e2:Entity.#id.#previous.startTime`, if e1 is the previous of e2.) Tarjan's algorithm is used to calculate the topological order of each node. Once the topological order of each node is known, to update from a set of changes: 1. Pick a changed node with the minimum topological order that was not visited. 2. Update the changed node. 3. If the value of the node changed, marked all its children as changed.
- Only facts of entities can be group - If e is in a group on g, then there is an edge from `e:Entity.#id.shadow` to `g:Entity.#id.group.shadow`.
ESC is consistent with Assertion ESC for visit group if we recalculate everything from scratch, which implies the graph edges/topological order is correct, but not everything that is changed is marked as changed.
… in ESC VisitGroups are now working on FULL_ASSERT
- Added fail-fasts for prohibited paths (that probably also did not work on the old impl anyway)
…shCode For large datasets, EntityVariablePair.hashCode dominated the flamegraph.
e4cb2d5
to
ec1e54e
Compare
|
VariableListeners are notoriously difficult to write, especially when you have multiple variable depending on each others.
Declarative shadow variables fixes this by providing a declarative way to define the dependencies of your shadow variables and how to calculate them.
First, annotate your variables as
@ProvidedShadowVariable
:Next, create a
ShadowVariableProvider
to define your variables:and finally, write tests to ensure your shadow variables work correctly:
Behind the scenes, Timefold Solver calculates a valid topological order for each of your shadow variables.
This allows Timefold Solver to:
Currently a WIP: