Skip to content

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

Draft
wants to merge 19 commits into
base: main
Choose a base branch
from

Conversation

Christopher-Chianelli
Copy link
Contributor

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:

@PlanningEntity
public class Visit {
    String id;

    @InvalidityMarker
    boolean isInvalid;   // If true, the visit is in a "loop" and some of its provided shadow variables will be null

    @ProvidedShadowVariable(MyShadowVariableProvider.class)
    LocalDateTime serviceReadyTime;
    
    // ...
}

Next, create a ShadowVariableProvider to define your variables:

public class MyShadowVariableProvider implements ShadowVariableProvider {
    @Override
    public void defineVariables(ShadowVariableFactory variableFactory) {
        // The previous visit; you don't need to use @PreviousElementShadowVariable
        var previousVisit = variableFactory.entity(Visit.class).previous();
        // The vehicle the visit is assignedf to; you don't need to use @InverseRelationShadowVariable
        var vehicle = variableFactory.entity(Visit.class).inverse(Vehicle.class);
        
        // The service ready time; when the technician will arrive
        var serviceReadyTime = variableFactory.newShadow(Visit.class)
               // If a visit has a previous visit, the ready time 
                .compute(previousVisit.fact(Location.class, Visit::getLocation),
                         previousVisit.variable(LocalDateTime.class, "serviceFinishTime"),
                         (visit, previousLocation, previousEndTime) -> previousEndTime.plus(visit.getLocation().travelTimeFrom(previousLocation))
                )
                // If a visit has no previous visit, use the vehicle's start time
                .orCompute(vehicle.fact(Location.class, Vehicle::getLocation),
                        vehicle.fact(LocalDateTime.class, Vehicle::getStartTime),
                        (visit, startLocation, startTime) -> startTime.plus(visit.getLocation().travelTimeFrom(startLocation))
                )
                // If a visit is unassigned, its serviceReadyTime is null
                .as("serviceReadyTime");
        
         // The service start time; when service can start
         // If the visit is a part of a group, service cannot start until all technicians arrive
         // Otherwise, it the same as the service start time
         var visitGroup = variableFactory
                        .entity(Visit.class)
                        .group(Visit.class, Visit::getVisitGroup);
         var serviceStartTime = variableFactory.newShadow(Visit.class)
                // If the visit has a visit group, its start time is the max of the ready times of the visit in its visit group
                .compute(visitGroup.variables(LocalDateTime.class, "serviceReadyTime"),
                        (visit, groupReadyTimes) -> groupReadyTimes.isEmpty() ? null : Collections.max(groupReadyTimes))
                // Otherwise, it the service ready time of this visit
                .orCompute(serviceReadyTime, (visit, readyTime) -> readyTime)
                .as("serviceStartTime");

         // Each service ends 30 minutes after it started
         var serviceFinishTime = variableFactory.newShadow(Visit.class)
                .compute(serviceStartTime, (visit, startTime) -> startTime.plusMinutes(30))
                .as("serviceFinishTime");
    }
}

and finally, write tests to ensure your shadow variables work correctly:

@Test
public void shadowVariables() {
      var sessionFactory = ShadowVariableSessionFactory.create(
              SolutionDescriptor.buildSolutionDescriptor(RoutePlan.class,
                      Vehicle.class, Visit.class),
              new MyShadowVariableProvider());

      var vehicle = Vehicle("v1");
      var visit1 = Visit("c1");
      var visit2 = Visit("c2");
      var visit3 = Visit("c3");

      var session = sessionFactory.forEntities(vehicle, visit1, visit2, visit3);
      session.setInverse(visit1, vehicle);
      session.setPrevious(visit2, visit1);
      session.setPrevious(visit3, visit2);
      session.updateVariables();

      assertThat(visit1.getServiceReadyTime()).isEqualTo(TestShadowVariableProvider.BASE_START_TIME);
      assertThat(visit1.getServiceStartTime()).isEqualTo(TestShadowVariableProvider.BASE_START_TIME);
      assertThat(visit1.getServiceFinishTime()).isEqualTo(TestShadowVariableProvider.BASE_START_TIME.plusMinutes(30L));
      assertThat(visit1.isInvalid()).isFalse();

      assertThat(visit2.getServiceReadyTime()).isEqualTo(TestShadowVariableProvider.BASE_START_TIME.plusMinutes(60L));
      assertThat(visit2.getServiceStartTime()).isEqualTo(TestShadowVariableProvider.BASE_START_TIME.plusMinutes(60L));
      assertThat(visit2.getServiceFinishTime()).isEqualTo(TestShadowVariableProvider.BASE_START_TIME.plusMinutes(90L));
      assertThat(visit2.isInvalid()).isFalse();

      assertThat(visit3.getServiceReadyTime()).isEqualTo(TestShadowVariableProvider.BASE_START_TIME.plusMinutes(120L));
      assertThat(visit3.getServiceStartTime()).isEqualTo(TestShadowVariableProvider.BASE_START_TIME.plusMinutes(120L));
      assertThat(visit3.getServiceFinishTime()).isEqualTo(TestShadowVariableProvider.BASE_START_TIME.plusMinutes(150L));
      assertThat(visit3.isInvalid()).isFalse();
}

Behind the scenes, Timefold Solver calculates a valid topological order for each of your shadow variables.
This allows Timefold Solver to:

  1. Only recalculate changed shadow variables. A shadow variable is recalculated if any of its declared inputs has changed. Using an undeclared variable input will lead to score corruptions.
  2. Determine what variables are in a "loop". Variables are in a "loop" if:
  • They directly or indirectly depend on each other.
  • They depend on looped variables.

Currently a WIP:

  • Write javadoc
  • Write documentation
  • Write additional tests
  • Write additional use cases
  • Finalize API

@triceo
Copy link
Contributor

triceo commented Feb 25, 2025

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.)

  • Are we happy with the package? Variables generally go into api.domain.variable, so in this case preview.api.domain.variable.declarative?
  • Preview features need to be declared in PreviewFeature; features not enabled through this enum need to fail fast when used. (In this case, probably when the new annotations are being processed.)

@@ -34,7 +49,9 @@
public final class VariableListenerSupport<Solution_> implements SupplyManager {
Copy link
Contributor

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.

@@ -0,0 +1,13 @@
package ai.timefold.solver.core.preview.api.variable.provided;

public interface ShadowVariableSession {
Copy link
Contributor

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.

Copy link
Contributor Author

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 {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Dtto.

@triceo
Copy link
Contributor

triceo commented Feb 26, 2025

Hypothetical scenario:

  • I have two different shadow providers.
  • But they end up declaring/depending on the same shadow variables.

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?

@Christopher-Chianelli
Copy link
Contributor Author

Hypothetical scenario:

* I have two different shadow providers.

* But they end up declaring/depending on the same shadow variables.

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).

@triceo
Copy link
Contributor

triceo commented Feb 26, 2025

Please add this to the list of things that need to be tested.

import org.jspecify.annotations.Nullable;

@NullMarked
public interface ShadowVariableSession {
Copy link
Contributor

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?

Copy link
Contributor

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.

Copy link
Contributor

@triceo triceo left a 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.
Copy link
Contributor

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);
Copy link
Contributor

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_> {
Copy link
Contributor

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.

Comment on lines +48 to +49
// var entityClass = variableDescriptor.getEntityDescriptor().getEntityClass();
// graph.afterVariableChanged(new VariableId(entityClass, variableDescriptor.getVariableName()),
// entity);
Copy link
Contributor

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 {
Copy link
Contributor

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 {
Copy link
Contributor

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 {
Copy link
Contributor

@triceo triceo Apr 12, 2025

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 {
Copy link
Contributor

@triceo triceo Apr 12, 2025

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 {
Copy link
Contributor

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 {
Copy link
Contributor

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.

Copy link
Contributor

@triceo triceo left a 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"/>
Copy link
Contributor

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 =
Copy link
Contributor

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.

Copy link
Contributor Author

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) {
Copy link
Contributor

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.
Copy link
Contributor

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.

Copy link
Contributor Author

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.

Copy link
Contributor

@triceo triceo Apr 15, 2025

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);
Copy link
Contributor

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) {
Copy link
Contributor

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;
}
Copy link
Contributor

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) {
Copy link
Contributor

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 {
Copy link
Contributor

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 {
Copy link
Contributor

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();
Copy link
Contributor

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();
Copy link
Contributor

@triceo triceo Apr 14, 2025

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.

Copy link
Contributor

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() {
Copy link
Contributor

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.
Copy link

Quality Gate Failed Quality Gate failed

Failed conditions
C Reliability Rating on New Code (required ≥ A)

See analysis details on SonarQube Cloud

Catch issues before they fail your Quality Gate with our IDE extension SonarQube for IDE

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants