Skip to content
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

feat: Declarative Shadow Variables #1421

Draft
wants to merge 12 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.

});
}

public static <Solution_> ChangedVariableNotifier<Solution_> of(InnerScoreDirector<Solution_, ?> scoreDirector) {
Copy link
Contributor

Choose a reason for hiding this comment

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

And this can be stored on a score director, meaning there would be a 1:1 mapping between these instances and score directors.

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 issue with storing it on the ScoreDirector is that the entire ScoreDirector would then need to be mocked to be mocked to implement tests. The class exists to abstract away the before...Change and after...Changed method calls (tests do not care, but they are required when solving).

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


@Target({ METHOD, FIELD })
@Retention(RUNTIME)
public @interface InvalidityMarker {
Copy link
Contributor

Choose a reason for hiding this comment

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

Will this mark any type of invalidity or only loops?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Any invalidity caused by loops (so a variable in a cycle OR a variable that depends on a variable in a cycle). Any type of invalidity is too vague; some people will consider null to be a valid values, others would not.

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?

return new MockShadowVariableSessionFactory<>(solutionDescriptor, shadowVariableProvider);
}

ShadowVariableSession forEntities(Object... entities);
Copy link
Contributor

Choose a reason for hiding this comment

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

Feels similar to ConstraintVerifier.given(), so maybe rename it to "given"?

import org.jspecify.annotations.NullMarked;

@NullMarked
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.

Maybe rename to something like ShadowVariableVerifier, as it serves a a simular purpose?


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.

Is FSR being copy pasted into the solver test sources?
Or is it a subset?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

A heavily simplified subset.

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

sonarqubecloud bot commented Mar 6, 2025

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