From 3247e86046612ef609ea77b08efa532464b66690 Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?Frederico=20Gon=C3=A7alves?=
 <zepfred@users.noreply.github.com>
Date: Wed, 15 Jan 2025 10:59:12 -0300
Subject: [PATCH] fix: Ruin&Recreate score corruption and multi-threaded fixes
 (#1320)

Fixes TimefoldAI/timefold-employee-scheduling#305
---
 .../DefaultConstructionHeuristicPhase.java    |   2 +-
 .../placer/AbstractEntityPlacer.java          |  17 ++
 .../placer/EntityPlacer.java                  |   1 +
 .../placer/PooledEntityPlacer.java            |   7 +-
 .../placer/PooledEntityPlacerFactory.java     |   2 +-
 .../placer/QueuedEntityPlacer.java            |   8 +-
 .../placer/QueuedEntityPlacerFactory.java     |   2 +-
 .../placer/QueuedValuePlacer.java             |   8 +-
 .../placer/QueuedValuePlacerFactory.java      |   3 +-
 .../impl/heuristic/HeuristicConfigPolicy.java |  10 ++
 ...uinRecreateConstructionHeuristicPhase.java |  34 +++-
 ...eateConstructionHeuristicPhaseBuilder.java |  53 +++++-
 ...eateConstructionHeuristicPhaseFactory.java |   2 +-
 .../move/generic/RuinRecreateMove.java        |  16 +-
 .../list/ruin/ListRuinRecreateMove.java       | 165 +++++++++++-------
 .../ruin/ListRuinRecreateMoveIterator.java    |   4 +-
 .../move/director/EphemeralMoveDirector.java  |   8 +-
 .../impl/move/director/RecordedUndoMove.java  |   3 +
 .../VariableChangeRecordingScoreDirector.java | 121 +++++++------
 .../score/director/InnerScoreDirector.java    |   8 +
 .../director/RevertableScoreDirector.java     |  20 +++
 .../BavetConstraintStreamScoreDirector.java   |   4 +
 .../placer/entity/PooledEntityPlacerTest.java |  38 ++--
 .../placer/entity/QueuedEntityPlacerTest.java |  94 +++++++---
 .../placer/entity/QueuedValuePlacerTest.java  |  51 ++++--
 ...ConstructionHeuristicPhaseBuilderTest.java |  48 +++++
 .../list/ListRuinRecreateMoveTest.java        |  16 +-
 .../impl/move/director/MoveDirectorTest.java  |  82 +++++++++
 28 files changed, 629 insertions(+), 198 deletions(-)
 create mode 100644 core/src/main/java/ai/timefold/solver/core/impl/score/director/RevertableScoreDirector.java
 create mode 100644 core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java

diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java
index 8bed6176b3..fbd76c4534 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/DefaultConstructionHeuristicPhase.java
@@ -101,7 +101,7 @@ protected ConstructionHeuristicPhaseScope<Solution_> buildPhaseScope(SolverScope
         return new ConstructionHeuristicPhaseScope<>(solverScope, phaseIndex);
     }
 
-    private void doStep(ConstructionHeuristicStepScope<Solution_> stepScope) {
+    protected void doStep(ConstructionHeuristicStepScope<Solution_> stepScope) {
         var step = stepScope.getStep();
         step.execute(stepScope.getMoveDirector());
         predictWorkingStepScore(stepScope, step);
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/AbstractEntityPlacer.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/AbstractEntityPlacer.java
index ccef6dca2b..25ce203dff 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/AbstractEntityPlacer.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/AbstractEntityPlacer.java
@@ -1,5 +1,6 @@
 package ai.timefold.solver.core.impl.constructionheuristic.placer;
 
+import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
 import ai.timefold.solver.core.impl.phase.event.PhaseLifecycleSupport;
 import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope;
 import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope;
@@ -17,8 +18,24 @@ public abstract class AbstractEntityPlacer<Solution_> implements EntityPlacer<So
 
     protected final transient Logger logger = LoggerFactory.getLogger(getClass());
 
+    protected final EntityPlacerFactory<Solution_> factory;
+    protected final HeuristicConfigPolicy<Solution_> configPolicy;
+
     protected PhaseLifecycleSupport<Solution_> phaseLifecycleSupport = new PhaseLifecycleSupport<>();
 
+    AbstractEntityPlacer(EntityPlacerFactory<Solution_> factory, HeuristicConfigPolicy<Solution_> configPolicy) {
+        this.factory = factory;
+        this.configPolicy = configPolicy;
+    }
+
+    @Override
+    public EntityPlacer<Solution_> copy() {
+        if (factory == null || configPolicy == null) {
+            throw new IllegalStateException("The entity placer cannot be copied.");
+        }
+        return factory.buildEntityPlacer(configPolicy.copyConfigPolicy());
+    }
+
     @Override
     public void solvingStarted(SolverScope<Solution_> solverScope) {
         phaseLifecycleSupport.fireSolvingStarted(solverScope);
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/EntityPlacer.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/EntityPlacer.java
index fb9019d57e..b52d0c45d1 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/EntityPlacer.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/EntityPlacer.java
@@ -7,4 +7,5 @@ public interface EntityPlacer<Solution_> extends Iterable<Placement<Solution_>>,
 
     EntityPlacer<Solution_> rebuildWithFilter(SelectionFilter<Solution_, Object> filter);
 
+    EntityPlacer<Solution_> copy();
 }
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/PooledEntityPlacer.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/PooledEntityPlacer.java
index 05120369ce..382bc07304 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/PooledEntityPlacer.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/PooledEntityPlacer.java
@@ -2,6 +2,7 @@
 
 import java.util.Iterator;
 
+import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
 import ai.timefold.solver.core.impl.heuristic.move.Move;
 import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.SelectionFilter;
 import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.UpcomingSelectionIterator;
@@ -13,7 +14,9 @@ public class PooledEntityPlacer<Solution_> extends AbstractEntityPlacer<Solution
 
     protected final MoveSelector<Solution_> moveSelector;
 
-    public PooledEntityPlacer(MoveSelector<Solution_> moveSelector) {
+    public PooledEntityPlacer(EntityPlacerFactory<Solution_> factory, HeuristicConfigPolicy<Solution_> configPolicy,
+            MoveSelector<Solution_> moveSelector) {
+        super(factory, configPolicy);
         this.moveSelector = moveSelector;
         phaseLifecycleSupport.addEventListener(moveSelector);
     }
@@ -25,7 +28,7 @@ public Iterator<Placement<Solution_>> iterator() {
 
     @Override
     public EntityPlacer<Solution_> rebuildWithFilter(SelectionFilter<Solution_, Object> filter) {
-        return new PooledEntityPlacer<>(FilteringMoveSelector.of(moveSelector, filter::accept));
+        return new PooledEntityPlacer<>(factory, configPolicy, FilteringMoveSelector.of(moveSelector, filter::accept));
     }
 
     private class PooledEntityPlacingIterator extends UpcomingSelectionIterator<Placement<Solution_>> {
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/PooledEntityPlacerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/PooledEntityPlacerFactory.java
index a9c0d4965c..34c9ebbb8f 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/PooledEntityPlacerFactory.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/PooledEntityPlacerFactory.java
@@ -69,7 +69,7 @@ public PooledEntityPlacer<Solution_> buildEntityPlacer(HeuristicConfigPolicy<Sol
 
         MoveSelector<Solution_> moveSelector = MoveSelectorFactory.<Solution_> create(moveSelectorConfig_)
                 .buildMoveSelector(configPolicy, SelectionCacheType.JUST_IN_TIME, SelectionOrder.ORIGINAL, false);
-        return new PooledEntityPlacer<>(moveSelector);
+        return new PooledEntityPlacer<>(this, configPolicy, moveSelector);
     }
 
     private MoveSelectorConfig buildMoveSelectorConfig(HeuristicConfigPolicy<Solution_> configPolicy) {
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacer.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacer.java
index f01e106abb..762e09fa7e 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacer.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacer.java
@@ -4,6 +4,7 @@
 import java.util.Iterator;
 import java.util.List;
 
+import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
 import ai.timefold.solver.core.impl.heuristic.move.Move;
 import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.SelectionFilter;
 import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.UpcomingSelectionIterator;
@@ -17,7 +18,9 @@ public class QueuedEntityPlacer<Solution_> extends AbstractEntityPlacer<Solution
     protected final EntitySelector<Solution_> entitySelector;
     protected final List<MoveSelector<Solution_>> moveSelectorList;
 
-    public QueuedEntityPlacer(EntitySelector<Solution_> entitySelector, List<MoveSelector<Solution_>> moveSelectorList) {
+    public QueuedEntityPlacer(EntityPlacerFactory<Solution_> factory, HeuristicConfigPolicy<Solution_> configPolicy,
+            EntitySelector<Solution_> entitySelector, List<MoveSelector<Solution_>> moveSelectorList) {
+        super(factory, configPolicy);
         this.entitySelector = entitySelector;
         this.moveSelectorList = moveSelectorList;
         phaseLifecycleSupport.addEventListener(entitySelector);
@@ -33,7 +36,8 @@ public Iterator<Placement<Solution_>> iterator() {
 
     @Override
     public EntityPlacer<Solution_> rebuildWithFilter(SelectionFilter<Solution_, Object> filter) {
-        return new QueuedEntityPlacer<>(FilteringEntitySelector.of(entitySelector, filter), moveSelectorList);
+        return new QueuedEntityPlacer<>(factory, configPolicy, FilteringEntitySelector.of(entitySelector, filter),
+                moveSelectorList);
     }
 
     private class QueuedEntityPlacingIterator extends UpcomingSelectionIterator<Placement<Solution_>> {
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacerFactory.java
index a33271f874..d013a1ec8b 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacerFactory.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedEntityPlacerFactory.java
@@ -76,7 +76,7 @@ public QueuedEntityPlacer<Solution_> buildEntityPlacer(HeuristicConfigPolicy<Sol
                     .buildMoveSelector(configPolicy, SelectionCacheType.JUST_IN_TIME, SelectionOrder.ORIGINAL, false);
             moveSelectorList.add(moveSelector);
         }
-        return new QueuedEntityPlacer<>(entitySelector, moveSelectorList);
+        return new QueuedEntityPlacer<>(this, configPolicy, entitySelector, moveSelectorList);
     }
 
     @SuppressWarnings("rawtypes")
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacer.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacer.java
index 8ea05add13..8a5b6ebf3c 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacer.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacer.java
@@ -3,6 +3,7 @@
 import java.util.Collections;
 import java.util.Iterator;
 
+import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
 import ai.timefold.solver.core.impl.heuristic.selector.common.decorator.SelectionFilter;
 import ai.timefold.solver.core.impl.heuristic.selector.common.iterator.UpcomingSelectionIterator;
 import ai.timefold.solver.core.impl.heuristic.selector.move.MoveSelector;
@@ -16,8 +17,9 @@ public class QueuedValuePlacer<Solution_> extends AbstractEntityPlacer<Solution_
     protected final EntityIndependentValueSelector<Solution_> valueSelector;
     protected final MoveSelector<Solution_> moveSelector;
 
-    public QueuedValuePlacer(EntityIndependentValueSelector<Solution_> valueSelector,
-            MoveSelector<Solution_> moveSelector) {
+    public QueuedValuePlacer(EntityPlacerFactory<Solution_> factory, HeuristicConfigPolicy<Solution_> configPolicy,
+            EntityIndependentValueSelector<Solution_> valueSelector, MoveSelector<Solution_> moveSelector) {
+        super(factory, configPolicy);
         this.valueSelector = valueSelector;
         this.moveSelector = moveSelector;
         phaseLifecycleSupport.addEventListener(valueSelector);
@@ -59,7 +61,7 @@ protected Placement<Solution_> createUpcomingSelection() {
 
     @Override
     public EntityPlacer<Solution_> rebuildWithFilter(SelectionFilter<Solution_, Object> filter) {
-        return new QueuedValuePlacer<>(
+        return new QueuedValuePlacer<>(factory, configPolicy,
                 (EntityIndependentFilteringValueSelector<Solution_>) FilteringValueSelector.of(valueSelector, filter),
                 moveSelector);
     }
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacerFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacerFactory.java
index 64777cf8eb..76a6332449 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacerFactory.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/constructionheuristic/placer/QueuedValuePlacerFactory.java
@@ -55,7 +55,8 @@ public QueuedValuePlacer<Solution_> buildEntityPlacer(HeuristicConfigPolicy<Solu
                     + " Check your @" + ValueRangeProvider.class.getSimpleName() + " annotations.");
 
         }
-        return new QueuedValuePlacer<>((EntityIndependentValueSelector<Solution_>) valueSelector, moveSelector);
+        return new QueuedValuePlacer<>(this, configPolicy, (EntityIndependentValueSelector<Solution_>) valueSelector,
+                moveSelector);
     }
 
     private ValueSelectorConfig buildValueSelectorConfig(HeuristicConfigPolicy<Solution_> configPolicy,
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java
index 6d93b44c3d..f769c37f62 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/HeuristicConfigPolicy.java
@@ -147,6 +147,16 @@ public Builder<Solution_> cloneBuilder() {
                 .withLogIndentation(logIndentation);
     }
 
+    public HeuristicConfigPolicy<Solution_> copyConfigPolicy() {
+        return cloneBuilder()
+                .withEntitySorterManner(entitySorterManner)
+                .withValueSorterManner(valueSorterManner)
+                .withReinitializeVariableFilterEnabled(reinitializeVariableFilterEnabled)
+                .withInitializedChainedValueFilterEnabled(initializedChainedValueFilterEnabled)
+                .withUnassignedValuesAllowed(unassignedValuesAllowed)
+                .build();
+    }
+
     public HeuristicConfigPolicy<Solution_> createPhaseConfigPolicy() {
         return cloneBuilder().build();
     }
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java
index 4783079923..1dfc6a22e7 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhase.java
@@ -1,16 +1,28 @@
 package ai.timefold.solver.core.impl.heuristic.selector.move.generic;
 
+import java.util.IdentityHashMap;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
 import ai.timefold.solver.core.impl.constructionheuristic.ConstructionHeuristicPhase;
 import ai.timefold.solver.core.impl.constructionheuristic.DefaultConstructionHeuristicPhase;
 import ai.timefold.solver.core.impl.constructionheuristic.scope.ConstructionHeuristicPhaseScope;
+import ai.timefold.solver.core.impl.constructionheuristic.scope.ConstructionHeuristicStepScope;
 import ai.timefold.solver.core.impl.solver.scope.SolverScope;
 
-final class RuinRecreateConstructionHeuristicPhase<Solution_>
+public final class RuinRecreateConstructionHeuristicPhase<Solution_>
         extends DefaultConstructionHeuristicPhase<Solution_>
         implements ConstructionHeuristicPhase<Solution_> {
 
+    private final Set<Object> elementsToRuinSet;
+    // Store the original value list of elements that are not included in the initial list of ruined elements
+    private final Map<Object, List<Object>> missingUpdatedElementsMap;
+
     RuinRecreateConstructionHeuristicPhase(RuinRecreateConstructionHeuristicPhaseBuilder<Solution_> builder) {
         super(builder);
+        this.elementsToRuinSet = builder.elementsToRuin;
+        this.missingUpdatedElementsMap = new IdentityHashMap<>();
     }
 
     @Override
@@ -28,4 +40,24 @@ public String getPhaseTypeString() {
         return "Ruin & Recreate Construction Heuristics";
     }
 
+    @Override
+    protected void doStep(ConstructionHeuristicStepScope<Solution_> stepScope) {
+        if (elementsToRuinSet != null) {
+            var listVariableDescriptor = stepScope.getPhaseScope().getSolverScope().getSolutionDescriptor()
+                    .getListVariableDescriptor();
+            var entity = stepScope.getStep().extractPlanningEntities().iterator().next();
+            if (!elementsToRuinSet.contains(entity)) {
+                // Sometimes, the list of elements to be ruined does not include new destinations selected by the CH.
+                // In these cases, we need to record the element list before making any move changes
+                // so that it can be referenced to restore the solution to its original state when undoing changes.
+                missingUpdatedElementsMap.computeIfAbsent(entity,
+                        e -> List.copyOf(listVariableDescriptor.getValue(e)));
+            }
+        }
+        super.doStep(stepScope);
+    }
+
+    public Map<Object, List<Object>> getMissingUpdatedElementsMap() {
+        return missingUpdatedElementsMap;
+    }
 }
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java
index ccb5bbd0f1..5f1c891ae3 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilder.java
@@ -1,6 +1,7 @@
 package ai.timefold.solver.core.impl.heuristic.selector.move.generic;
 
 import java.util.List;
+import java.util.Set;
 
 import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig;
 import ai.timefold.solver.core.config.solver.termination.TerminationConfig;
@@ -8,6 +9,7 @@
 import ai.timefold.solver.core.impl.constructionheuristic.decider.ConstructionHeuristicDecider;
 import ai.timefold.solver.core.impl.constructionheuristic.placer.EntityPlacer;
 import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
+import ai.timefold.solver.core.impl.score.director.InnerScoreDirector;
 import ai.timefold.solver.core.impl.solver.termination.Termination;
 import ai.timefold.solver.core.impl.solver.termination.TerminationFactory;
 
@@ -24,16 +26,49 @@ public static <Solution_> RuinRecreateConstructionHeuristicPhaseBuilder<Solution
             HeuristicConfigPolicy<Solution_> solverConfigPolicy, ConstructionHeuristicPhaseConfig constructionHeuristicConfig) {
         var constructionHeuristicPhaseFactory =
                 new RuinRecreateConstructionHeuristicPhaseFactory<Solution_>(constructionHeuristicConfig);
-        return (RuinRecreateConstructionHeuristicPhaseBuilder<Solution_>) constructionHeuristicPhaseFactory.getBuilder(0, false,
+        var builder = (RuinRecreateConstructionHeuristicPhaseBuilder<Solution_>) constructionHeuristicPhaseFactory.getBuilder(0,
+                false,
                 solverConfigPolicy, TerminationFactory.<Solution_> create(new TerminationConfig())
                         .buildTermination(solverConfigPolicy));
+        if (solverConfigPolicy.getMoveThreadCount() != null && solverConfigPolicy.getMoveThreadCount() >= 1) {
+            builder.multithreaded = true;
+        }
+        return builder;
     }
 
-    private List<Object> elementsToRecreate;
+    private final HeuristicConfigPolicy<Solution_> configPolicy;
+    private final RuinRecreateConstructionHeuristicPhaseFactory<Solution_> constructionHeuristicPhaseFactory;
+    private final Termination<Solution_> phaseTermination;
+
+    Set<Object> elementsToRuin;
+    List<Object> elementsToRecreate;
+    private boolean multithreaded = false;
 
-    RuinRecreateConstructionHeuristicPhaseBuilder(Termination<Solution_> phaseTermination,
-            EntityPlacer<Solution_> entityPlacer, ConstructionHeuristicDecider<Solution_> decider) {
+    RuinRecreateConstructionHeuristicPhaseBuilder(HeuristicConfigPolicy<Solution_> configPolicy,
+            RuinRecreateConstructionHeuristicPhaseFactory<Solution_> constructionHeuristicPhaseFactory,
+            Termination<Solution_> phaseTermination, EntityPlacer<Solution_> entityPlacer,
+            ConstructionHeuristicDecider<Solution_> decider) {
         super(0, false, "", phaseTermination, entityPlacer, decider);
+        this.configPolicy = configPolicy;
+        this.constructionHeuristicPhaseFactory = constructionHeuristicPhaseFactory;
+        this.phaseTermination = phaseTermination;
+    }
+
+    /**
+     * In a multithreaded environment, the builder will be shared among all moves and threads.
+     * Consequently, the list {@code elementsToRecreate} used by {@code getEntityPlacer} or the {@code decider},
+     * will be shared between the main and move threads.
+     * This sharing can lead to race conditions.
+     * The method creates a new copy of the builder and the decider to avoid race conditions.
+     */
+    public RuinRecreateConstructionHeuristicPhaseBuilder<Solution_>
+            ensureThreadSafe(InnerScoreDirector<Solution_, ?> scoreDirector) {
+        if (multithreaded && scoreDirector.isDerived()) {
+            return new RuinRecreateConstructionHeuristicPhaseBuilder<>(configPolicy, constructionHeuristicPhaseFactory,
+                    phaseTermination, super.getEntityPlacer().copy(),
+                    constructionHeuristicPhaseFactory.buildDecider(configPolicy, phaseTermination));
+        }
+        return this;
     }
 
     public RuinRecreateConstructionHeuristicPhaseBuilder<Solution_> withElementsToRecreate(List<Object> elements) {
@@ -41,12 +76,18 @@ public RuinRecreateConstructionHeuristicPhaseBuilder<Solution_> withElementsToRe
         return this;
     }
 
+    public RuinRecreateConstructionHeuristicPhaseBuilder<Solution_> withElementsToRuin(Set<Object> elements) {
+        this.elementsToRuin = elements;
+        return this;
+    }
+
     @Override
     public EntityPlacer<Solution_> getEntityPlacer() {
+        var placer = super.getEntityPlacer();
         if (elementsToRecreate == null || elementsToRecreate.isEmpty()) {
-            return super.getEntityPlacer();
+            return placer;
         }
-        return super.getEntityPlacer().rebuildWithFilter((scoreDirector, selection) -> {
+        return placer.rebuildWithFilter((scoreDirector, selection) -> {
             for (var element : elementsToRecreate) {
                 if (selection == element) {
                     return true;
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java
index f09f7d19fe..f4c0772215 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseFactory.java
@@ -23,7 +23,7 @@ protected DefaultConstructionHeuristicPhaseBuilder<Solution_> createBuilder(
             Termination<Solution_> solverTermination, int phaseIndex, boolean triggerFirstInitializedSolutionEvent,
             EntityPlacer<Solution_> entityPlacer) {
         var phaseTermination = new PhaseToSolverTerminationBridge<>(new BasicPlumbingTermination<Solution_>(false));
-        return new RuinRecreateConstructionHeuristicPhaseBuilder<>(phaseTermination, entityPlacer,
+        return new RuinRecreateConstructionHeuristicPhaseBuilder<>(phaseConfigPolicy, this, phaseTermination, entityPlacer,
                 buildDecider(phaseConfigPolicy, phaseTermination));
 
     }
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateMove.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateMove.java
index abd97055ba..bfe128e047 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateMove.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateMove.java
@@ -10,7 +10,7 @@
 import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor;
 import ai.timefold.solver.core.impl.heuristic.move.AbstractMove;
 import ai.timefold.solver.core.impl.heuristic.move.Move;
-import ai.timefold.solver.core.impl.score.director.VariableDescriptorAwareScoreDirector;
+import ai.timefold.solver.core.impl.move.director.VariableChangeRecordingScoreDirector;
 import ai.timefold.solver.core.impl.solver.scope.SolverScope;
 
 public final class RuinRecreateMove<Solution_> extends AbstractMove<Solution_> {
@@ -38,21 +38,23 @@ public RuinRecreateMove(GenuineVariableDescriptor<Solution_> genuineVariableDesc
     protected void doMoveOnGenuineVariables(ScoreDirector<Solution_> scoreDirector) {
         recordedNewValues = new Object[ruinedEntityList.size()];
 
-        var castScoreDirector = (VariableDescriptorAwareScoreDirector<Solution_>) scoreDirector;
+        var recordingScoreDirector = (VariableChangeRecordingScoreDirector<Solution_>) scoreDirector;
         for (var ruinedEntity : ruinedEntityList) {
-            castScoreDirector.beforeVariableChanged(genuineVariableDescriptor, ruinedEntity);
+            recordingScoreDirector.beforeVariableChanged(genuineVariableDescriptor, ruinedEntity);
             genuineVariableDescriptor.setValue(ruinedEntity, null);
-            castScoreDirector.afterVariableChanged(genuineVariableDescriptor, ruinedEntity);
+            recordingScoreDirector.afterVariableChanged(genuineVariableDescriptor, ruinedEntity);
         }
-        castScoreDirector.triggerVariableListeners();
+        recordingScoreDirector.triggerVariableListeners();
 
-        var constructionHeuristicPhase = constructionHeuristicPhaseBuilder.withElementsToRecreate(ruinedEntityList)
+        var constructionHeuristicPhase = constructionHeuristicPhaseBuilder
+                .ensureThreadSafe(recordingScoreDirector.getBacking())
+                .withElementsToRecreate(ruinedEntityList)
                 .build();
         constructionHeuristicPhase.setSolver(solverScope.getSolver());
         constructionHeuristicPhase.solvingStarted(solverScope);
         constructionHeuristicPhase.solve(solverScope);
         constructionHeuristicPhase.solvingEnded(solverScope);
-        castScoreDirector.triggerVariableListeners();
+        recordingScoreDirector.triggerVariableListeners();
 
         for (var i = 0; i < ruinedEntityList.size(); i++) {
             recordedNewValues[i] = genuineVariableDescriptor.getValue(ruinedEntityList.get(i));
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMove.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMove.java
index 5aed630aa8..5f0a82d2eb 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMove.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMove.java
@@ -10,27 +10,29 @@
 import java.util.TreeSet;
 
 import ai.timefold.solver.core.api.score.director.ScoreDirector;
-import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply;
+import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor;
 import ai.timefold.solver.core.impl.heuristic.move.AbstractMove;
 import ai.timefold.solver.core.impl.heuristic.move.Move;
+import ai.timefold.solver.core.impl.heuristic.selector.move.generic.RuinRecreateConstructionHeuristicPhase;
 import ai.timefold.solver.core.impl.heuristic.selector.move.generic.RuinRecreateConstructionHeuristicPhaseBuilder;
 import ai.timefold.solver.core.impl.move.director.VariableChangeRecordingScoreDirector;
+import ai.timefold.solver.core.impl.score.director.InnerScoreDirector;
 import ai.timefold.solver.core.impl.solver.scope.SolverScope;
 import ai.timefold.solver.core.impl.util.CollectionUtils;
 
 public final class ListRuinRecreateMove<Solution_> extends AbstractMove<Solution_> {
 
-    private final ListVariableStateSupply<Solution_> listVariableStateSupply;
+    private final ListVariableDescriptor<Solution_> listVariableDescriptor;
     private final List<Object> ruinedValueList;
     private final Set<Object> affectedEntitySet;
     private final RuinRecreateConstructionHeuristicPhaseBuilder<Solution_> constructionHeuristicPhaseBuilder;
     private final SolverScope<Solution_> solverScope;
     private final Map<Object, NavigableSet<RuinedLocation>> entityToNewPositionMap;
 
-    public ListRuinRecreateMove(ListVariableStateSupply<Solution_> listVariableStateSupply,
+    public ListRuinRecreateMove(ListVariableDescriptor<Solution_> listVariableDescriptor,
             RuinRecreateConstructionHeuristicPhaseBuilder<Solution_> constructionHeuristicPhaseBuilder,
             SolverScope<Solution_> solverScope, List<Object> ruinedValueList, Set<Object> affectedEntitySet) {
-        this.listVariableStateSupply = listVariableStateSupply;
+        this.listVariableDescriptor = listVariableDescriptor;
         this.constructionHeuristicPhaseBuilder = constructionHeuristicPhaseBuilder;
         this.solverScope = solverScope;
         this.ruinedValueList = ruinedValueList;
@@ -41,63 +43,106 @@ public ListRuinRecreateMove(ListVariableStateSupply<Solution_> listVariableState
     @Override
     protected void doMoveOnGenuineVariables(ScoreDirector<Solution_> scoreDirector) {
         entityToNewPositionMap.clear();
-        var entityToOriginalPositionMap =
-                CollectionUtils.<Object, NavigableSet<RuinedLocation>> newIdentityHashMap(affectedEntitySet.size());
-        for (var valueToRuin : ruinedValueList) {
-            var location = listVariableStateSupply.getLocationInList(valueToRuin)
-                    .ensureAssigned();
-            entityToOriginalPositionMap.computeIfAbsent(location.entity(),
-                    ignored -> new TreeSet<>()).add(new RuinedLocation(valueToRuin, location.index()));
-        }
+        var variableChangeRecordingScoreDirector = (VariableChangeRecordingScoreDirector<Solution_>) scoreDirector;
+        try (var listVariableStateSupply = variableChangeRecordingScoreDirector.getBacking().getSupplyManager()
+                .demand(listVariableDescriptor.getStateDemand())) {
+            var entityToOriginalPositionMap =
+                    CollectionUtils.<Object, NavigableSet<RuinedLocation>> newIdentityHashMap(affectedEntitySet.size());
+            for (var valueToRuin : ruinedValueList) {
+                var location = listVariableStateSupply.getLocationInList(valueToRuin)
+                        .ensureAssigned();
+                entityToOriginalPositionMap.computeIfAbsent(location.entity(),
+                        ignored -> new TreeSet<>()).add(new RuinedLocation(valueToRuin, location.index()));
+            }
 
-        var listVariableDescriptor = listVariableStateSupply.getSourceVariableDescriptor();
-        var recordingScoreDirector = (VariableChangeRecordingScoreDirector<Solution_>) scoreDirector;
-        var nonRecordingScoreDirector = recordingScoreDirector.getDelegate();
-        for (var entry : entityToOriginalPositionMap.entrySet()) {
-            var entity = entry.getKey();
-            var originalPositionSet = entry.getValue();
-
-            // Only record before(), so we can restore the state.
-            // The after() is sent straight to the real score director.
-            recordingScoreDirector.beforeListVariableChanged(listVariableDescriptor, entity,
-                    listVariableDescriptor.getFirstUnpinnedIndex(entity),
-                    listVariableDescriptor.getListSize(entity));
-            for (var position : originalPositionSet.descendingSet()) {
-                recordingScoreDirector.beforeListVariableElementUnassigned(listVariableDescriptor, position.ruinedValue());
-                listVariableDescriptor.removeElement(entity, position.index());
-                recordingScoreDirector.afterListVariableElementUnassigned(listVariableDescriptor, position.ruinedValue());
+            var nonRecordingScoreDirector = variableChangeRecordingScoreDirector.getBacking();
+            for (var entry : entityToOriginalPositionMap.entrySet()) {
+                var entity = entry.getKey();
+                var originalPositionSet = entry.getValue();
+
+                // Only record before(), so we can restore the state.
+                // The after() is sent straight to the real score director.
+                variableChangeRecordingScoreDirector.beforeListVariableChanged(listVariableDescriptor, entity,
+                        listVariableDescriptor.getFirstUnpinnedIndex(entity),
+                        listVariableDescriptor.getListSize(entity));
+                for (var position : originalPositionSet.descendingSet()) {
+                    variableChangeRecordingScoreDirector.beforeListVariableElementUnassigned(listVariableDescriptor,
+                            position.ruinedValue());
+                    listVariableDescriptor.removeElement(entity, position.index());
+                    variableChangeRecordingScoreDirector.afterListVariableElementUnassigned(listVariableDescriptor,
+                            position.ruinedValue());
+                }
+                nonRecordingScoreDirector.afterListVariableChanged(listVariableDescriptor, entity,
+                        listVariableDescriptor.getFirstUnpinnedIndex(entity),
+                        listVariableDescriptor.getListSize(entity));
+            }
+            scoreDirector.triggerVariableListeners();
+
+            var constructionHeuristicPhase =
+                    (RuinRecreateConstructionHeuristicPhase<Solution_>) constructionHeuristicPhaseBuilder
+                            .ensureThreadSafe(variableChangeRecordingScoreDirector.getBacking())
+                            .withElementsToRuin(entityToOriginalPositionMap.keySet())
+                            .withElementsToRecreate(ruinedValueList)
+                            .build();
+
+            var nestedSolverScope = new SolverScope<Solution_>();
+            nestedSolverScope.setSolver(solverScope.getSolver());
+            nestedSolverScope.setScoreDirector(variableChangeRecordingScoreDirector.getBacking());
+            constructionHeuristicPhase.setSolver(nestedSolverScope.getSolver());
+            constructionHeuristicPhase.solvingStarted(nestedSolverScope);
+            constructionHeuristicPhase.solve(nestedSolverScope);
+            constructionHeuristicPhase.solvingEnded(nestedSolverScope);
+            scoreDirector.triggerVariableListeners();
+
+            var entityToInsertedValuesMap = CollectionUtils.<Object, List<Object>> newIdentityHashMap(0);
+            for (var entity : entityToOriginalPositionMap.keySet()) {
+                entityToInsertedValuesMap.put(entity, new ArrayList<>());
             }
-            nonRecordingScoreDirector.afterListVariableChanged(listVariableDescriptor, entity,
-                    listVariableDescriptor.getFirstUnpinnedIndex(entity),
-                    listVariableDescriptor.getListSize(entity));
-        }
-        scoreDirector.triggerVariableListeners();
-
-        var constructionHeuristicPhase = constructionHeuristicPhaseBuilder.withElementsToRecreate(ruinedValueList)
-                .build();
-        constructionHeuristicPhase.setSolver(solverScope.getSolver());
-        constructionHeuristicPhase.solvingStarted(solverScope);
-        constructionHeuristicPhase.solve(solverScope);
-        constructionHeuristicPhase.solvingEnded(solverScope);
-        scoreDirector.triggerVariableListeners();
-
-        var entityToInsertedValuesMap = CollectionUtils.<Object, List<Object>> newIdentityHashMap(0);
-        for (var entity : entityToOriginalPositionMap.keySet()) {
-            entityToInsertedValuesMap.put(entity, new ArrayList<>());
-        }
 
-        for (var ruinedValue : ruinedValueList) {
-            var location = listVariableStateSupply.getLocationInList(ruinedValue)
-                    .ensureAssigned();
-            entityToNewPositionMap.computeIfAbsent(location.entity(), ignored -> new TreeSet<>())
-                    .add(new RuinedLocation(ruinedValue, location.index()));
-            entityToInsertedValuesMap.computeIfAbsent(location.entity(), ignored -> new ArrayList<>()).add(ruinedValue);
-        }
+            for (var ruinedValue : ruinedValueList) {
+                var location = listVariableStateSupply.getLocationInList(ruinedValue)
+                        .ensureAssigned();
+                entityToNewPositionMap.computeIfAbsent(location.entity(), ignored -> new TreeSet<>())
+                        .add(new RuinedLocation(ruinedValue, location.index()));
+                entityToInsertedValuesMap.computeIfAbsent(location.entity(), ignored -> new ArrayList<>()).add(ruinedValue);
+            }
 
-        for (var entry : entityToInsertedValuesMap.entrySet()) {
-            recordingScoreDirector.recordListAssignment(listVariableDescriptor, entry.getKey(), entry.getValue());
+            var onlyRecordingChangesScoreDirector = variableChangeRecordingScoreDirector.getNonDelegating();
+            for (var entry : entityToInsertedValuesMap.entrySet()) {
+                if (!entityToOriginalPositionMap.containsKey(entry.getKey())) {
+                    // The entity has not been evaluated while creating the entityToOriginalPositionMap,
+                    // meaning it is a new destination entity without a ListVariableBeforeChangeAction
+                    // to restore the original elements.
+                    // We need to ensure the before action is executed in order to restore the original elements.
+                    var originalElementList =
+                            constructionHeuristicPhase.getMissingUpdatedElementsMap().get(entry.getKey());
+                    var currentElementList = List.copyOf(listVariableDescriptor.getValue(entry.getKey()));
+                    // We need to first update the entity element list before tracking changes
+                    // and set it back to the one from the generated solution
+                    listVariableDescriptor.getValue(entry.getKey()).clear();
+                    listVariableDescriptor.getValue(entry.getKey()).addAll(originalElementList);
+                    onlyRecordingChangesScoreDirector.beforeListVariableChanged(listVariableDescriptor, entry.getKey(), 0,
+                            originalElementList.size());
+                    listVariableDescriptor.getValue(entry.getKey()).clear();
+                    listVariableDescriptor.getValue(entry.getKey()).addAll(currentElementList);
+                }
+                // Since the solution was generated through a nested phase,
+                // all actions taken to produce the solution are not accessible.
+                // Therefore, we need to replicate all the actions required to generate the solution
+                // while also allowing for restoring the original state.
+                for (var element : entry.getValue()) {
+                    onlyRecordingChangesScoreDirector.beforeListVariableElementAssigned(listVariableDescriptor, element);
+                }
+                onlyRecordingChangesScoreDirector.afterListVariableChanged(listVariableDescriptor, entry.getKey(),
+                        listVariableDescriptor.getFirstUnpinnedIndex(entry.getKey()),
+                        listVariableDescriptor.getListSize(entry.getKey()));
+                for (var element : entry.getValue()) {
+                    onlyRecordingChangesScoreDirector.afterListVariableElementAssigned(listVariableDescriptor, element);
+                }
+            }
+            variableChangeRecordingScoreDirector.getBacking().getSupplyManager()
+                    .cancel(listVariableDescriptor.getStateDemand());
         }
-
     }
 
     @Override
@@ -119,7 +164,9 @@ public boolean isMoveDoable(ScoreDirector<Solution_> scoreDirector) {
     public Move<Solution_> rebase(ScoreDirector<Solution_> destinationScoreDirector) {
         var rebasedRuinedValueList = AbstractMove.rebaseList(ruinedValueList, destinationScoreDirector);
         var rebasedAffectedEntitySet = AbstractMove.rebaseSet(affectedEntitySet, destinationScoreDirector);
-        return new ListRuinRecreateMove<>(listVariableStateSupply, constructionHeuristicPhaseBuilder, solverScope,
+        var rebasedListVariableDescriptor = ((InnerScoreDirector<Solution_, ?>) destinationScoreDirector)
+                .getSolutionDescriptor().getListVariableDescriptor();
+        return new ListRuinRecreateMove<>(rebasedListVariableDescriptor, constructionHeuristicPhaseBuilder, solverScope,
                 rebasedRuinedValueList, rebasedAffectedEntitySet);
     }
 
@@ -129,14 +176,14 @@ public boolean equals(Object o) {
             return true;
         if (!(o instanceof ListRuinRecreateMove<?> that))
             return false;
-        return Objects.equals(listVariableStateSupply, that.listVariableStateSupply)
+        return Objects.equals(listVariableDescriptor, that.listVariableDescriptor)
                 && Objects.equals(ruinedValueList, that.ruinedValueList)
                 && Objects.equals(affectedEntitySet, that.affectedEntitySet);
     }
 
     @Override
     public int hashCode() {
-        return Objects.hash(listVariableStateSupply, ruinedValueList, affectedEntitySet);
+        return Objects.hash(listVariableDescriptor, ruinedValueList, affectedEntitySet);
     }
 
     @Override
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveIterator.java b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveIterator.java
index 73c18a505b..88c50591ae 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveIterator.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ruin/ListRuinRecreateMoveIterator.java
@@ -68,8 +68,8 @@ protected Move<Solution_> createUpcomingSelection() {
                 }
             }
         }
-        return new ListRuinRecreateMove<>(listVariableStateSupply, constructionHeuristicPhaseBuilder, solverScope,
-                selectedValueList, affectedEntitySet);
+        return new ListRuinRecreateMove<>(listVariableStateSupply.getSourceVariableDescriptor(),
+                constructionHeuristicPhaseBuilder, solverScope, selectedValueList, affectedEntitySet);
     }
 
 }
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/director/EphemeralMoveDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/director/EphemeralMoveDirector.java
index dacaf49bf1..84e2010cc5 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/move/director/EphemeralMoveDirector.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/move/director/EphemeralMoveDirector.java
@@ -1,5 +1,7 @@
 package ai.timefold.solver.core.impl.move.director;
 
+import java.util.List;
+
 import ai.timefold.solver.core.impl.score.director.VariableDescriptorAwareScoreDirector;
 import ai.timefold.solver.core.preview.api.domain.metamodel.ElementLocation;
 import ai.timefold.solver.core.preview.api.domain.metamodel.PlanningListVariableMetaModel;
@@ -21,15 +23,17 @@ public final class EphemeralMoveDirector<Solution_> extends MoveDirector<Solutio
         super(new VariableChangeRecordingScoreDirector<>(scoreDirector, false));
     }
 
+    @SuppressWarnings("unchecked")
     public Move<Solution_> createUndoMove() {
-        return new RecordedUndoMove<>(getVariableChangeRecordingScoreDirector().copyVariableChanges());
+        var changes = (List<ChangeAction<Solution_>>) getVariableChangeRecordingScoreDirector().copyChanges();
+        return new RecordedUndoMove<>(changes);
     }
 
     @Override
     public <Entity_, Value_> @NonNull ElementLocation getPositionOf(
             @NonNull PlanningListVariableMetaModel<Solution_, Entity_, Value_> variableMetaModel,
             @NonNull Value_ value) {
-        return getPositionOf(getVariableChangeRecordingScoreDirector().getDelegate(), variableMetaModel, value);
+        return getPositionOf(getVariableChangeRecordingScoreDirector().getBacking(), variableMetaModel, value);
 
     }
 
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/director/RecordedUndoMove.java b/core/src/main/java/ai/timefold/solver/core/impl/move/director/RecordedUndoMove.java
index 37d4a9b23f..01e52662af 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/move/director/RecordedUndoMove.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/move/director/RecordedUndoMove.java
@@ -33,4 +33,7 @@ public void execute(@NonNull MutableSolutionView<Solution_> solutionView) {
                 .toList());
     }
 
+    List<ChangeAction<Solution_>> getVariableChangeActionList() {
+        return variableChangeActionList;
+    }
 }
\ No newline at end of file
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/move/director/VariableChangeRecordingScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/move/director/VariableChangeRecordingScoreDirector.java
index 9bd6c54e6b..bb17755800 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/move/director/VariableChangeRecordingScoreDirector.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/move/director/VariableChangeRecordingScoreDirector.java
@@ -4,6 +4,7 @@
 import java.util.LinkedList;
 import java.util.List;
 import java.util.Map;
+import java.util.Objects;
 
 import ai.timefold.solver.core.api.score.director.ScoreDirector;
 import ai.timefold.solver.core.impl.domain.solution.descriptor.SolutionDescriptor;
@@ -11,13 +12,15 @@
 import ai.timefold.solver.core.impl.domain.variable.descriptor.VariableDescriptor;
 import ai.timefold.solver.core.impl.heuristic.move.AbstractMove;
 import ai.timefold.solver.core.impl.score.director.InnerScoreDirector;
-import ai.timefold.solver.core.impl.score.director.VariableDescriptorAwareScoreDirector;
+import ai.timefold.solver.core.impl.score.director.RevertableScoreDirector;
 import ai.timefold.solver.core.impl.score.director.VariableDescriptorCache;
 
-public final class VariableChangeRecordingScoreDirector<Solution_> implements VariableDescriptorAwareScoreDirector<Solution_> {
+public final class VariableChangeRecordingScoreDirector<Solution_>
+        implements RevertableScoreDirector<Solution_> {
 
-    private final InnerScoreDirector<Solution_, ?> delegate;
+    private final InnerScoreDirector<Solution_, ?> backingScoreDirector;
     private final List<ChangeAction<Solution_>> variableChanges;
+
     /*
      * The fromIndex of afterListVariableChanged must match the fromIndex of its beforeListVariableChanged call.
      * Otherwise this will happen in the undo move:
@@ -37,22 +40,32 @@ public final class VariableChangeRecordingScoreDirector<Solution_> implements Va
      */
     private final Map<Object, Integer> cache;
 
-    public VariableChangeRecordingScoreDirector(ScoreDirector<Solution_> delegate) {
-        this(delegate, true);
+    public VariableChangeRecordingScoreDirector(ScoreDirector<Solution_> backingScoreDirector) {
+        this(backingScoreDirector, true);
     }
 
-    public VariableChangeRecordingScoreDirector(ScoreDirector<Solution_> delegate, boolean requiresIndexCache) {
-        this.delegate = (InnerScoreDirector<Solution_, ?>) delegate;
+    public VariableChangeRecordingScoreDirector(ScoreDirector<Solution_> backingScoreDirector, boolean requiresIndexCache) {
+        this.backingScoreDirector = (InnerScoreDirector<Solution_, ?>) backingScoreDirector;
         this.cache = requiresIndexCache ? new IdentityHashMap<>() : null;
         // Intentional LinkedList; fast clear, no allocations upfront,
         // will most often only carry a small number of items.
         this.variableChanges = new LinkedList<>();
     }
 
-    List<ChangeAction<Solution_>> copyVariableChanges() {
+    private VariableChangeRecordingScoreDirector(InnerScoreDirector<Solution_, ?> backingScoreDirector,
+            List<ChangeAction<Solution_>> variableChanges, Map<Object, Integer> cache) {
+        this.backingScoreDirector = backingScoreDirector;
+        this.variableChanges = variableChanges;
+        this.cache = cache;
+    }
+
+    @Override
+    @SuppressWarnings("unchecked")
+    public List<?> copyChanges() {
         return List.copyOf(variableChanges);
     }
 
+    @Override
     public void undoChanges() {
         var changeCount = variableChanges.size();
         if (changeCount == 0) {
@@ -61,9 +74,9 @@ public void undoChanges() {
         var listIterator = variableChanges.listIterator(changeCount);
         while (listIterator.hasPrevious()) { // Iterate in reverse.
             var changeAction = listIterator.previous();
-            changeAction.undo(delegate);
+            changeAction.undo(backingScoreDirector);
         }
-        delegate.triggerVariableListeners();
+        Objects.requireNonNull(backingScoreDirector).triggerVariableListeners();
         variableChanges.clear();
         if (cache != null) {
             cache.clear();
@@ -73,12 +86,16 @@ public void undoChanges() {
     @Override
     public void beforeVariableChanged(VariableDescriptor<Solution_> variableDescriptor, Object entity) {
         variableChanges.add(new VariableChangeAction<>(entity, variableDescriptor.getValue(entity), variableDescriptor));
-        delegate.beforeVariableChanged(variableDescriptor, entity);
+        if (backingScoreDirector != null) {
+            backingScoreDirector.beforeVariableChanged(variableDescriptor, entity);
+        }
     }
 
     @Override
     public void afterVariableChanged(VariableDescriptor<Solution_> variableDescriptor, Object entity) {
-        delegate.afterVariableChanged(variableDescriptor, entity);
+        if (backingScoreDirector != null) {
+            backingScoreDirector.afterVariableChanged(variableDescriptor, entity);
+        }
     }
 
     @Override
@@ -92,7 +109,9 @@ public void beforeListVariableChanged(ListVariableDescriptor<Solution_> variable
         variableChanges.add(new ListVariableBeforeChangeAction<>(entity,
                 List.copyOf(list.subList(fromIndex, toIndex)), fromIndex, toIndex,
                 variableDescriptor));
-        delegate.beforeListVariableChanged(variableDescriptor, entity, fromIndex, toIndex);
+        if (backingScoreDirector != null) {
+            backingScoreDirector.beforeListVariableChanged(variableDescriptor, entity, fromIndex, toIndex);
+        }
     }
 
     @Override
@@ -109,67 +128,92 @@ The fromIndex of afterListVariableChanged (%d) must match the fromIndex of its b
             }
         }
         variableChanges.add(new ListVariableAfterChangeAction<>(entity, fromIndex, toIndex, variableDescriptor));
-        delegate.afterListVariableChanged(variableDescriptor, entity, fromIndex, toIndex);
+        if (backingScoreDirector != null) {
+            backingScoreDirector.afterListVariableChanged(variableDescriptor, entity, fromIndex, toIndex);
+        }
     }
 
     @Override
     public void beforeListVariableElementAssigned(ListVariableDescriptor<Solution_> variableDescriptor, Object element) {
         variableChanges.add(new ListVariableBeforeAssignmentAction<>(element, variableDescriptor));
-        delegate.beforeListVariableElementAssigned(variableDescriptor, element);
+        if (backingScoreDirector != null) {
+            backingScoreDirector.beforeListVariableElementAssigned(variableDescriptor, element);
+        }
     }
 
     @Override
     public void afterListVariableElementAssigned(ListVariableDescriptor<Solution_> variableDescriptor, Object element) {
         variableChanges.add(new ListVariableAfterAssignmentAction<>(element, variableDescriptor));
-        delegate.afterListVariableElementAssigned(variableDescriptor, element);
+        if (backingScoreDirector != null) {
+            backingScoreDirector.afterListVariableElementAssigned(variableDescriptor, element);
+        }
     }
 
     @Override
     public void beforeListVariableElementUnassigned(ListVariableDescriptor<Solution_> variableDescriptor, Object element) {
         variableChanges.add(new ListVariableBeforeUnassignmentAction<>(element, variableDescriptor));
-        delegate.beforeListVariableElementUnassigned(variableDescriptor, element);
+        if (backingScoreDirector != null) {
+            backingScoreDirector.beforeListVariableElementUnassigned(variableDescriptor, element);
+        }
     }
 
     @Override
     public void afterListVariableElementUnassigned(ListVariableDescriptor<Solution_> variableDescriptor, Object element) {
         variableChanges.add(new ListVariableAfterUnassignmentAction<>(element, variableDescriptor));
-        delegate.afterListVariableElementUnassigned(variableDescriptor, element);
+        if (backingScoreDirector != null) {
+            backingScoreDirector.afterListVariableElementUnassigned(variableDescriptor, element);
+        }
     }
 
     // For other operations, call the delegate's method.
 
     @Override
     public SolutionDescriptor<Solution_> getSolutionDescriptor() {
-        return delegate.getSolutionDescriptor();
+        return Objects.requireNonNull(backingScoreDirector).getSolutionDescriptor();
+    }
+
+    /**
+     * Returns the score director to which events are delegated.
+     */
+    public InnerScoreDirector<Solution_, ?> getBacking() {
+        return backingScoreDirector;
     }
 
-    public InnerScoreDirector<Solution_, ?> getDelegate() {
-        return delegate;
+    /**
+     * The {@code VariableChangeRecordingScoreDirector} score director includes two main tasks:
+     * tracking any variable change and firing events to a delegated score director.
+     * This method returns a copy of the score director
+     * that only tracks variable changes without firing any delegated score director events.
+     */
+    public VariableChangeRecordingScoreDirector<Solution_> getNonDelegating() {
+        return new VariableChangeRecordingScoreDirector<>(null, variableChanges, cache);
     }
 
     @Override
     public Solution_ getWorkingSolution() {
-        return delegate.getWorkingSolution();
+        return Objects.requireNonNull(backingScoreDirector).getWorkingSolution();
     }
 
     @Override
     public VariableDescriptorCache<Solution_> getVariableDescriptorCache() {
-        return delegate.getVariableDescriptorCache();
+        return Objects.requireNonNull(backingScoreDirector).getVariableDescriptorCache();
     }
 
     @Override
     public void triggerVariableListeners() {
-        delegate.triggerVariableListeners();
+        if (backingScoreDirector != null) {
+            backingScoreDirector.triggerVariableListeners();
+        }
     }
 
     @Override
     public <E> E lookUpWorkingObject(E externalObject) {
-        return delegate.lookUpWorkingObject(externalObject);
+        return Objects.requireNonNull(backingScoreDirector).lookUpWorkingObject(externalObject);
     }
 
     @Override
     public <E> E lookUpWorkingObjectOrReturnNull(E externalObject) {
-        return delegate.lookUpWorkingObjectOrReturnNull(externalObject);
+        return Objects.requireNonNull(backingScoreDirector).lookUpWorkingObjectOrReturnNull(externalObject);
     }
 
     @Override
@@ -178,29 +222,4 @@ public void changeVariableFacade(VariableDescriptor<Solution_> variableDescripto
         variableDescriptor.setValue(entity, newValue);
         afterVariableChanged(variableDescriptor, entity);
     }
-
-    /**
-     * Record a list assignment.
-     * Used by moves with nested phases, such as ruin and recreate.
-     * These moves need to record some changes manually,
-     * because they cannot influence what will happen in the nested phases.
-     * Cannot be sent via the before/after events, because we don't want to delegate to the actual score director;
-     * these new changes should only be used for undoing the move(s).
-     *
-     * @param variableDescriptor never null
-     * @param entity never null
-     * @param values never null, may be empty
-     */
-    public void recordListAssignment(ListVariableDescriptor<Solution_> variableDescriptor, Object entity,
-            List<Object> values) {
-        for (var element : values) {
-            variableChanges.add(new ListVariableBeforeAssignmentAction<>(element, variableDescriptor));
-        }
-        variableChanges.add(new ListVariableAfterChangeAction<>(entity,
-                variableDescriptor.getFirstUnpinnedIndex(entity), variableDescriptor.getListSize(entity),
-                variableDescriptor));
-        for (var element : values) {
-            variableChanges.add(new ListVariableAfterAssignmentAction<>(element, variableDescriptor));
-        }
-    }
 }
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java
index e86d8409ac..fcfe6454a6 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/InnerScoreDirector.java
@@ -338,6 +338,14 @@ default Solution_ cloneWorkingSolution() {
      */
     void forceTriggerVariableListeners();
 
+    /**
+     * A derived score director is created from a root score director.
+     * The derived score director can be used to create separate* instances for use cases like multithreaded solving.
+     */
+    default boolean isDerived() {
+        return false;
+    }
+
     default ScoreAnalysis<Score_> buildScoreAnalysis(ScoreAnalysisFetchPolicy scoreAnalysisFetchPolicy) {
         return buildScoreAnalysis(scoreAnalysisFetchPolicy, ScoreAnalysisMode.DEFAULT);
     }
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/RevertableScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/RevertableScoreDirector.java
new file mode 100644
index 0000000000..1a319ad911
--- /dev/null
+++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/RevertableScoreDirector.java
@@ -0,0 +1,20 @@
+package ai.timefold.solver.core.impl.score.director;
+
+import java.util.List;
+
+public interface RevertableScoreDirector<Solution_> extends VariableDescriptorAwareScoreDirector<Solution_> {
+
+    /**
+     * Use this method to get a copy of all non-commited changes executed by the director so far.
+     * 
+     * @param <Action_> The action type for recorded changes
+     */
+    <Action_> List<Action_> copyChanges();
+
+    /**
+     * Use this method to revert all changes made by moves.
+     * The score director that implements this logic must be able to track every single change in the solution and
+     * restore it to its original state.
+     */
+    void undoChanges();
+}
diff --git a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java
index 7cfa71b508..0395aefb96 100644
--- a/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java
+++ b/core/src/main/java/ai/timefold/solver/core/impl/score/director/stream/BavetConstraintStreamScoreDirector.java
@@ -177,4 +177,8 @@ public BavetConstraintSession<Score_> getSession() {
         return session;
     }
 
+    @Override
+    public boolean isDerived() {
+        return derived;
+    }
 }
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/PooledEntityPlacerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/PooledEntityPlacerTest.java
index ca997bb7dc..bb77528246 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/PooledEntityPlacerTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/PooledEntityPlacerTest.java
@@ -3,16 +3,21 @@
 import static ai.timefold.solver.core.impl.testdata.util.PlannerAssert.assertAllCodesOfIterator;
 import static ai.timefold.solver.core.impl.testdata.util.PlannerAssert.verifyPhaseLifecycle;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import java.util.Iterator;
 
+import ai.timefold.solver.core.impl.constructionheuristic.placer.EntityPlacerFactory;
 import ai.timefold.solver.core.impl.constructionheuristic.placer.Placement;
 import ai.timefold.solver.core.impl.constructionheuristic.placer.PooledEntityPlacer;
+import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
 import ai.timefold.solver.core.impl.heuristic.move.DummyMove;
 import ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils;
-import ai.timefold.solver.core.impl.heuristic.selector.move.MoveSelector;
 import ai.timefold.solver.core.impl.phase.scope.AbstractPhaseScope;
 import ai.timefold.solver.core.impl.phase.scope.AbstractStepScope;
 import ai.timefold.solver.core.impl.solver.scope.SolverScope;
@@ -24,21 +29,21 @@ class PooledEntityPlacerTest {
 
     @Test
     void oneMoveSelector() {
-        MoveSelector<TestdataSolution> moveSelector = SelectorTestUtils.mockMoveSelector(
+        var moveSelector = SelectorTestUtils.mockMoveSelector(
                 new DummyMove("a1"), new DummyMove("a2"), new DummyMove("b1"));
 
-        PooledEntityPlacer<TestdataSolution> placer = new PooledEntityPlacer<>(moveSelector);
+        var placer = new PooledEntityPlacer<>(null, null, moveSelector);
 
-        SolverScope<TestdataSolution> solverScope = mock(SolverScope.class);
+        var solverScope = mock(SolverScope.class);
         placer.solvingStarted(solverScope);
 
-        AbstractPhaseScope<TestdataSolution> phaseScopeA = mock(AbstractPhaseScope.class);
+        var phaseScopeA = mock(AbstractPhaseScope.class);
         when(phaseScopeA.getSolverScope()).thenReturn(solverScope);
         placer.phaseStarted(phaseScopeA);
         Iterator<Placement<TestdataSolution>> placementIterator = placer.iterator();
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeA1 = mock(AbstractStepScope.class);
+        var stepScopeA1 = mock(AbstractStepScope.class);
         when(stepScopeA1.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA1);
         assertAllCodesOfIterator(placementIterator.next().iterator(),
@@ -46,7 +51,7 @@ void oneMoveSelector() {
         placer.stepEnded(stepScopeA1);
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeA2 = mock(AbstractStepScope.class);
+        var stepScopeA2 = mock(AbstractStepScope.class);
         when(stepScopeA2.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA2);
         assertAllCodesOfIterator(placementIterator.next().iterator(),
@@ -54,7 +59,7 @@ void oneMoveSelector() {
         placer.stepEnded(stepScopeA2);
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeA3 = mock(AbstractStepScope.class);
+        var stepScopeA3 = mock(AbstractStepScope.class);
         when(stepScopeA3.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA3);
         assertAllCodesOfIterator(placementIterator.next().iterator(),
@@ -63,13 +68,13 @@ void oneMoveSelector() {
 
         placer.phaseEnded(phaseScopeA);
 
-        AbstractPhaseScope<TestdataSolution> phaseScopeB = mock(AbstractPhaseScope.class);
+        var phaseScopeB = mock(AbstractPhaseScope.class);
         when(phaseScopeB.getSolverScope()).thenReturn(solverScope);
         placer.phaseStarted(phaseScopeB);
         placementIterator = placer.iterator();
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeB1 = mock(AbstractStepScope.class);
+        var stepScopeB1 = mock(AbstractStepScope.class);
         when(stepScopeB1.getPhaseScope()).thenReturn(phaseScopeB);
         placer.stepStarted(stepScopeB1);
         assertAllCodesOfIterator(placementIterator.next().iterator(),
@@ -83,4 +88,17 @@ void oneMoveSelector() {
         verifyPhaseLifecycle(moveSelector, 1, 2, 4);
     }
 
+    @Test
+    void copy() {
+        var moveSelector = SelectorTestUtils
+                .mockMoveSelector(new DummyMove("a1"), new DummyMove("a2"), new DummyMove("b1"));
+        var factory = mock(EntityPlacerFactory.class);
+        var configPolicy = mock(HeuristicConfigPolicy.class);
+        assertThatThrownBy(() -> new PooledEntityPlacer<>(null, null, moveSelector).copy())
+                .hasMessage("The entity placer cannot be copied.");
+        var placer = new PooledEntityPlacer<TestdataSolution>(factory, configPolicy, moveSelector);
+        placer.copy();
+        verify(factory, times(1)).buildEntityPlacer(any());
+    }
+
 }
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerTest.java
index 199e47156c..6e6ff39b3d 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedEntityPlacerTest.java
@@ -4,16 +4,21 @@
 import static ai.timefold.solver.core.impl.testdata.util.PlannerAssert.assertAllCodesOfIterator;
 import static ai.timefold.solver.core.impl.testdata.util.PlannerAssert.verifyPhaseLifecycle;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import java.util.ArrayList;
 import java.util.Collections;
 import java.util.Iterator;
-import java.util.List;
 
+import ai.timefold.solver.core.impl.constructionheuristic.placer.EntityPlacerFactory;
 import ai.timefold.solver.core.impl.constructionheuristic.placer.Placement;
 import ai.timefold.solver.core.impl.constructionheuristic.placer.QueuedEntityPlacer;
+import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
 import ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils;
 import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector;
 import ai.timefold.solver.core.impl.heuristic.selector.entity.mimic.MimicRecordingEntitySelector;
@@ -44,35 +49,34 @@ void oneMoveSelector() {
         ValueSelector<TestdataSolution> valueSelector = SelectorTestUtils.mockValueSelector(TestdataEntity.class, "value",
                 new TestdataValue("1"), new TestdataValue("2"));
 
-        MoveSelector<TestdataSolution> moveSelector =
+        var moveSelector =
                 new ChangeMoveSelector<>(new MimicReplayingEntitySelector<>(recordingEntitySelector), valueSelector, false);
-        QueuedEntityPlacer<TestdataSolution> placer =
-                new QueuedEntityPlacer<>(recordingEntitySelector, Collections.singletonList(moveSelector));
+        var placer = new QueuedEntityPlacer<>(null, null, recordingEntitySelector, Collections.singletonList(moveSelector));
 
-        SolverScope<TestdataSolution> solverScope = mock(SolverScope.class);
+        var solverScope = mock(SolverScope.class);
         placer.solvingStarted(solverScope);
 
-        AbstractPhaseScope<TestdataSolution> phaseScopeA = mock(AbstractPhaseScope.class);
+        var phaseScopeA = mock(AbstractPhaseScope.class);
         when(phaseScopeA.getSolverScope()).thenReturn(solverScope);
         placer.phaseStarted(phaseScopeA);
-        Iterator<Placement<TestdataSolution>> placementIterator = placer.iterator();
+        var placementIterator = placer.iterator();
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeA1 = mock(AbstractStepScope.class);
+        var stepScopeA1 = mock(AbstractStepScope.class);
         when(stepScopeA1.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA1);
         assertEntityPlacement(placementIterator.next(), "a", "1", "2");
         placer.stepEnded(stepScopeA1);
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeA2 = mock(AbstractStepScope.class);
+        var stepScopeA2 = mock(AbstractStepScope.class);
         when(stepScopeA2.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA2);
         assertEntityPlacement(placementIterator.next(), "b", "1", "2");
         placer.stepEnded(stepScopeA2);
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeA3 = mock(AbstractStepScope.class);
+        var stepScopeA3 = mock(AbstractStepScope.class);
         when(stepScopeA3.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA3);
         assertEntityPlacement(placementIterator.next(), "c", "1", "2");
@@ -81,13 +85,13 @@ void oneMoveSelector() {
         assertThat(placementIterator).isExhausted();
         placer.phaseEnded(phaseScopeA);
 
-        AbstractPhaseScope<TestdataSolution> phaseScopeB = mock(AbstractPhaseScope.class);
+        var phaseScopeB = mock(AbstractPhaseScope.class);
         when(phaseScopeB.getSolverScope()).thenReturn(solverScope);
         placer.phaseStarted(phaseScopeB);
         placementIterator = placer.iterator();
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeB1 = mock(AbstractStepScope.class);
+        var stepScopeB1 = mock(AbstractStepScope.class);
         when(stepScopeB1.getPhaseScope()).thenReturn(phaseScopeB);
         placer.stepStarted(stepScopeB1);
         assertEntityPlacement(placementIterator.next(), "a", "1", "2");
@@ -115,7 +119,7 @@ void multiQueuedMoveSelector() {
                 TestdataMultiVarEntity.class, "secondaryValue",
                 new TestdataValue("8"), new TestdataValue("9"));
 
-        List<MoveSelector<TestdataMultiVarSolution>> moveSelectorList = new ArrayList<>(2);
+        var moveSelectorList = new ArrayList<MoveSelector<TestdataMultiVarSolution>>(2);
         moveSelectorList.add(new ChangeMoveSelector<>(new MimicReplayingEntitySelector<>(recordingEntitySelector),
                 primaryValueSelector,
                 false));
@@ -123,40 +127,40 @@ void multiQueuedMoveSelector() {
                 new MimicReplayingEntitySelector<>(recordingEntitySelector),
                 secondaryValueSelector,
                 false));
-        QueuedEntityPlacer<TestdataMultiVarSolution> placer =
-                new QueuedEntityPlacer<>(recordingEntitySelector, moveSelectorList);
+        var placer =
+                new QueuedEntityPlacer<>(null, null, recordingEntitySelector, moveSelectorList);
 
-        SolverScope<TestdataMultiVarSolution> solverScope = mock(SolverScope.class);
+        var solverScope = mock(SolverScope.class);
         placer.solvingStarted(solverScope);
 
-        AbstractPhaseScope<TestdataMultiVarSolution> phaseScopeA = mock(AbstractPhaseScope.class);
+        var phaseScopeA = mock(AbstractPhaseScope.class);
         when(phaseScopeA.getSolverScope()).thenReturn(solverScope);
         placer.phaseStarted(phaseScopeA);
         Iterator<Placement<TestdataMultiVarSolution>> placementIterator = placer.iterator();
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataMultiVarSolution> stepScopeA1 = mock(AbstractStepScope.class);
+        var stepScopeA1 = mock(AbstractStepScope.class);
         when(stepScopeA1.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA1);
         assertEntityPlacement(placementIterator.next(), "a", "1", "2", "3");
         placer.stepEnded(stepScopeA1);
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataMultiVarSolution> stepScopeA2 = mock(AbstractStepScope.class);
+        var stepScopeA2 = mock(AbstractStepScope.class);
         when(stepScopeA2.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA2);
         assertEntityPlacement(placementIterator.next(), "a", "8", "9");
         placer.stepEnded(stepScopeA2);
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataMultiVarSolution> stepScopeA3 = mock(AbstractStepScope.class);
+        var stepScopeA3 = mock(AbstractStepScope.class);
         when(stepScopeA3.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA3);
         assertEntityPlacement(placementIterator.next(), "b", "1", "2", "3");
         placer.stepEnded(stepScopeA3);
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataMultiVarSolution> stepScopeA4 = mock(AbstractStepScope.class);
+        var stepScopeA4 = mock(AbstractStepScope.class);
         when(stepScopeA4.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA4);
         assertEntityPlacement(placementIterator.next(), "b", "8", "9");
@@ -186,27 +190,27 @@ void cartesianProductMoveSelector() {
                 TestdataMultiVarEntity.class, "secondaryValue",
                 new TestdataValue("8"), new TestdataValue("9"));
 
-        List<MoveSelector<TestdataMultiVarSolution>> moveSelectorList = new ArrayList<>(2);
+        var moveSelectorList = new ArrayList<MoveSelector<TestdataMultiVarSolution>>(2);
         moveSelectorList.add(new ChangeMoveSelector<>(new MimicReplayingEntitySelector<>(recordingEntitySelector),
                 primaryValueSelector,
                 false));
         moveSelectorList.add(new ChangeMoveSelector<>(new MimicReplayingEntitySelector<>(recordingEntitySelector),
                 secondaryValueSelector,
                 false));
-        MoveSelector<TestdataMultiVarSolution> moveSelector = new CartesianProductMoveSelector<>(moveSelectorList, true, false);
-        QueuedEntityPlacer<TestdataMultiVarSolution> placer = new QueuedEntityPlacer<>(recordingEntitySelector,
+        var moveSelector = new CartesianProductMoveSelector<>(moveSelectorList, true, false);
+        var placer = new QueuedEntityPlacer<>(null, null, recordingEntitySelector,
                 Collections.singletonList(moveSelector));
 
-        SolverScope<TestdataMultiVarSolution> solverScope = mock(SolverScope.class);
+        var solverScope = mock(SolverScope.class);
         placer.solvingStarted(solverScope);
 
-        AbstractPhaseScope<TestdataMultiVarSolution> phaseScopeA = mock(AbstractPhaseScope.class);
+        var phaseScopeA = mock(AbstractPhaseScope.class);
         when(phaseScopeA.getSolverScope()).thenReturn(solverScope);
         placer.phaseStarted(phaseScopeA);
         Iterator<Placement<TestdataMultiVarSolution>> placementIterator = placer.iterator();
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataMultiVarSolution> stepScopeA1 = mock(AbstractStepScope.class);
+        var stepScopeA1 = mock(AbstractStepScope.class);
         when(stepScopeA1.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA1);
         assertAllCodesOfIterator(placementIterator.next().iterator(),
@@ -214,7 +218,7 @@ void cartesianProductMoveSelector() {
         placer.stepEnded(stepScopeA1);
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataMultiVarSolution> stepScopeA2 = mock(AbstractStepScope.class);
+        var stepScopeA2 = mock(AbstractStepScope.class);
         when(stepScopeA2.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA2);
         assertAllCodesOfIterator(placementIterator.next().iterator(),
@@ -231,4 +235,38 @@ void cartesianProductMoveSelector() {
         verifyPhaseLifecycle(secondaryValueSelector, 1, 1, 2);
     }
 
+    @Test
+    void copy() {
+        EntitySelector<TestdataMultiVarSolution> entitySelector =
+                SelectorTestUtils.mockEntitySelector(TestdataMultiVarEntity.class,
+                        new TestdataMultiVarEntity("a"), new TestdataMultiVarEntity("b"));
+        MimicRecordingEntitySelector<TestdataMultiVarSolution> recordingEntitySelector =
+                new MimicRecordingEntitySelector<>(entitySelector);
+        ValueSelector<TestdataMultiVarSolution> primaryValueSelector = SelectorTestUtils.mockValueSelector(
+                TestdataMultiVarEntity.class, "primaryValue",
+                new TestdataValue("1"), new TestdataValue("2"), new TestdataValue("3"));
+        ValueSelector<TestdataMultiVarSolution> secondaryValueSelector = SelectorTestUtils.mockValueSelector(
+                TestdataMultiVarEntity.class, "secondaryValue",
+                new TestdataValue("8"), new TestdataValue("9"));
+        var moveSelectorList = new ArrayList<MoveSelector<TestdataMultiVarSolution>>(2);
+        moveSelectorList.add(new ChangeMoveSelector<>(
+                new MimicReplayingEntitySelector<>(recordingEntitySelector),
+                primaryValueSelector,
+                false));
+        moveSelectorList.add(
+                new ChangeMoveSelector<>(new MimicReplayingEntitySelector<>(recordingEntitySelector), secondaryValueSelector,
+                        false));
+        var moveSelector = new CartesianProductMoveSelector<>(moveSelectorList, true, false);
+        var factory = mock(EntityPlacerFactory.class);
+        var configPolicy = mock(HeuristicConfigPolicy.class);
+        assertThatThrownBy(
+                () -> new QueuedEntityPlacer<>(null, null, recordingEntitySelector, Collections.singletonList(moveSelector))
+                        .copy())
+                .hasMessage("The entity placer cannot be copied.");
+        var placer = new QueuedEntityPlacer<TestdataMultiVarSolution>(factory, configPolicy, recordingEntitySelector,
+                Collections.singletonList(moveSelector));
+        placer.copy();
+        verify(factory, times(1)).buildEntityPlacer(any());
+    }
+
 }
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedValuePlacerTest.java b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedValuePlacerTest.java
index 8cf480ab88..ee7cd26bca 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedValuePlacerTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/constructionheuristic/placer/entity/QueuedValuePlacerTest.java
@@ -3,17 +3,21 @@
 import static ai.timefold.solver.core.impl.constructionheuristic.placer.entity.PlacementAssertions.assertValuePlacement;
 import static ai.timefold.solver.core.impl.testdata.util.PlannerAssert.verifyPhaseLifecycle;
 import static org.assertj.core.api.Assertions.assertThat;
+import static org.assertj.core.api.Assertions.assertThatThrownBy;
+import static org.mockito.ArgumentMatchers.any;
 import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.times;
+import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
 import java.util.Iterator;
 
+import ai.timefold.solver.core.impl.constructionheuristic.placer.EntityPlacerFactory;
 import ai.timefold.solver.core.impl.constructionheuristic.placer.Placement;
 import ai.timefold.solver.core.impl.constructionheuristic.placer.QueuedValuePlacer;
-import ai.timefold.solver.core.impl.domain.variable.descriptor.GenuineVariableDescriptor;
+import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
 import ai.timefold.solver.core.impl.heuristic.selector.SelectorTestUtils;
 import ai.timefold.solver.core.impl.heuristic.selector.entity.EntitySelector;
-import ai.timefold.solver.core.impl.heuristic.selector.move.MoveSelector;
 import ai.timefold.solver.core.impl.heuristic.selector.move.generic.ChangeMoveSelector;
 import ai.timefold.solver.core.impl.heuristic.selector.value.EntityIndependentValueSelector;
 import ai.timefold.solver.core.impl.heuristic.selector.value.mimic.MimicRecordingValueSelector;
@@ -31,7 +35,7 @@ class QueuedValuePlacerTest {
 
     @Test
     void oneMoveSelector() {
-        GenuineVariableDescriptor<TestdataSolution> variableDescriptor = TestdataEntity.buildVariableDescriptorForValue();
+        var variableDescriptor = TestdataEntity.buildVariableDescriptorForValue();
         EntitySelector<TestdataSolution> entitySelector =
                 SelectorTestUtils.mockEntitySelector(variableDescriptor.getEntityDescriptor(),
                         new TestdataEntity("a"), new TestdataEntity("b"), new TestdataEntity("c"));
@@ -41,34 +45,34 @@ void oneMoveSelector() {
         MimicRecordingValueSelector<TestdataSolution> recordingValueSelector =
                 new MimicRecordingValueSelector<>(valueSelector);
 
-        MoveSelector<TestdataSolution> moveSelector = new ChangeMoveSelector<>(entitySelector,
+        var moveSelector = new ChangeMoveSelector<>(entitySelector,
                 new MimicReplayingValueSelector<>(recordingValueSelector), false);
-        QueuedValuePlacer<TestdataSolution> placer = new QueuedValuePlacer<>(recordingValueSelector, moveSelector);
+        var placer = new QueuedValuePlacer<>(null, null, recordingValueSelector, moveSelector);
 
-        SolverScope<TestdataSolution> solverScope = mock(SolverScope.class);
+        var solverScope = mock(SolverScope.class);
         placer.solvingStarted(solverScope);
 
-        AbstractPhaseScope<TestdataSolution> phaseScopeA = mock(AbstractPhaseScope.class);
+        var phaseScopeA = mock(AbstractPhaseScope.class);
         when(phaseScopeA.getSolverScope()).thenReturn(solverScope);
         placer.phaseStarted(phaseScopeA);
         Iterator<Placement<TestdataSolution>> placementIterator = placer.iterator();
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeA1 = mock(AbstractStepScope.class);
+        var stepScopeA1 = mock(AbstractStepScope.class);
         when(stepScopeA1.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA1);
         assertValuePlacement(placementIterator.next(), "1", "a", "b", "c");
         placer.stepEnded(stepScopeA1);
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeA2 = mock(AbstractStepScope.class);
+        var stepScopeA2 = mock(AbstractStepScope.class);
         when(stepScopeA2.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA2);
         assertValuePlacement(placementIterator.next(), "2", "a", "b", "c");
         placer.stepEnded(stepScopeA2);
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeA3 = mock(AbstractStepScope.class);
+        var stepScopeA3 = mock(AbstractStepScope.class);
         when(stepScopeA3.getPhaseScope()).thenReturn(phaseScopeA);
         placer.stepStarted(stepScopeA3);
         assertValuePlacement(placementIterator.next(), "1", "a", "b", "c");
@@ -78,13 +82,13 @@ void oneMoveSelector() {
         // assertFalse(placementIterator.hasNext());
         placer.phaseEnded(phaseScopeA);
 
-        AbstractPhaseScope<TestdataSolution> phaseScopeB = mock(AbstractPhaseScope.class);
+        var phaseScopeB = mock(AbstractPhaseScope.class);
         when(phaseScopeB.getSolverScope()).thenReturn(solverScope);
         placer.phaseStarted(phaseScopeB);
         placementIterator = placer.iterator();
 
         assertThat(placementIterator).hasNext();
-        AbstractStepScope<TestdataSolution> stepScopeB1 = mock(AbstractStepScope.class);
+        var stepScopeB1 = mock(AbstractStepScope.class);
         when(stepScopeB1.getPhaseScope()).thenReturn(phaseScopeB);
         placer.stepStarted(stepScopeB1);
         assertValuePlacement(placementIterator.next(), "1", "a", "b", "c");
@@ -98,4 +102,27 @@ void oneMoveSelector() {
         verifyPhaseLifecycle(valueSelector, 1, 2, 4);
     }
 
+    @Test
+    void copy() {
+        var variableDescriptor = TestdataEntity.buildVariableDescriptorForValue();
+        EntitySelector<TestdataSolution> entitySelector =
+                SelectorTestUtils.mockEntitySelector(variableDescriptor.getEntityDescriptor(),
+                        new TestdataEntity("a"), new TestdataEntity("b"), new TestdataEntity("c"));
+        EntityIndependentValueSelector<TestdataSolution> valueSelector = SelectorTestUtils.mockEntityIndependentValueSelector(
+                variableDescriptor,
+                new TestdataValue("1"), new TestdataValue("2"));
+        MimicRecordingValueSelector<TestdataSolution> recordingValueSelector =
+                new MimicRecordingValueSelector<>(valueSelector);
+
+        var moveSelector = new ChangeMoveSelector<>(entitySelector,
+                new MimicReplayingValueSelector<>(recordingValueSelector), false);
+        var factory = mock(EntityPlacerFactory.class);
+        var configPolicy = mock(HeuristicConfigPolicy.class);
+        assertThatThrownBy(() -> new QueuedValuePlacer<>(null, null, recordingValueSelector, moveSelector).copy())
+                .hasMessage("The entity placer cannot be copied.");
+        var placer = new QueuedValuePlacer<TestdataSolution>(factory, configPolicy, recordingValueSelector, moveSelector);
+        placer.copy();
+        verify(factory, times(1)).buildEntityPlacer(any());
+    }
+
 }
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java
new file mode 100644
index 0000000000..e197dbeaa9
--- /dev/null
+++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/RuinRecreateConstructionHeuristicPhaseBuilderTest.java
@@ -0,0 +1,48 @@
+package ai.timefold.solver.core.impl.heuristic.selector.move.generic;
+
+import static org.assertj.core.api.Assertions.assertThat;
+import static org.mockito.Mockito.mock;
+import static org.mockito.Mockito.when;
+
+import ai.timefold.solver.core.config.constructionheuristic.ConstructionHeuristicPhaseConfig;
+import ai.timefold.solver.core.config.score.trend.InitializingScoreTrendLevel;
+import ai.timefold.solver.core.impl.heuristic.HeuristicConfigPolicy;
+import ai.timefold.solver.core.impl.score.director.InnerScoreDirector;
+import ai.timefold.solver.core.impl.score.trend.InitializingScoreTrend;
+import ai.timefold.solver.core.impl.testdata.domain.TestdataSolution;
+
+import org.junit.jupiter.api.Test;
+
+class RuinRecreateConstructionHeuristicPhaseBuilderTest {
+
+    @Test
+    void buildSingleThreaded() {
+        var solverConfigPolicy = new HeuristicConfigPolicy.Builder<TestdataSolution>()
+                .withSolutionDescriptor(TestdataSolution.buildSolutionDescriptor())
+                .withInitializingScoreTrend(new InitializingScoreTrend(new InitializingScoreTrendLevel[] {
+                        InitializingScoreTrendLevel.ANY, InitializingScoreTrendLevel.ANY, InitializingScoreTrendLevel.ANY }))
+                .build();
+        var constructionHeuristicConfig = mock(ConstructionHeuristicPhaseConfig.class);
+        var builder = RuinRecreateConstructionHeuristicPhaseBuilder.create(solverConfigPolicy, constructionHeuristicConfig);
+        var phase = builder.build();
+        assertThat(phase.getEntityPlacer()).isSameAs(builder.getEntityPlacer());
+    }
+
+    @Test
+    void buildMultiThreaded() {
+        var solverConfigPolicy = new HeuristicConfigPolicy.Builder<TestdataSolution>()
+                .withSolutionDescriptor(TestdataSolution.buildSolutionDescriptor())
+                .withMoveThreadCount(2)
+                .withInitializingScoreTrend(new InitializingScoreTrend(new InitializingScoreTrendLevel[] {
+                        InitializingScoreTrendLevel.ANY, InitializingScoreTrendLevel.ANY, InitializingScoreTrendLevel.ANY }))
+                .build();
+        var constructionHeuristicConfig = mock(ConstructionHeuristicPhaseConfig.class);
+        var builder = RuinRecreateConstructionHeuristicPhaseBuilder.create(solverConfigPolicy, constructionHeuristicConfig);
+        var scoreDirector = mock(InnerScoreDirector.class);
+        when(scoreDirector.isDerived()).thenReturn(true);
+        var phase = builder
+                .ensureThreadSafe(scoreDirector)
+                .build();
+        assertThat(phase.getEntityPlacer()).isNotSameAs(builder.getEntityPlacer());
+    }
+}
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListRuinRecreateMoveTest.java b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListRuinRecreateMoveTest.java
index 132600ecec..0b88ac7033 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListRuinRecreateMoveTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/heuristic/selector/move/generic/list/ListRuinRecreateMoveTest.java
@@ -10,7 +10,7 @@
 import java.util.List;
 import java.util.Set;
 
-import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply;
+import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor;
 import ai.timefold.solver.core.impl.heuristic.selector.move.generic.RuinRecreateConstructionHeuristicPhaseBuilder;
 import ai.timefold.solver.core.impl.heuristic.selector.move.generic.list.ruin.ListRuinRecreateMove;
 import ai.timefold.solver.core.impl.solver.scope.SolverScope;
@@ -48,7 +48,7 @@ void rebase() {
                         { e3, destinationE3 },
                 });
 
-        var move = new ListRuinRecreateMove<TestdataListSolution>(mock(ListVariableStateSupply.class),
+        var move = new ListRuinRecreateMove<TestdataListSolution>(mock(ListVariableDescriptor.class),
                 mock(RuinRecreateConstructionHeuristicPhaseBuilder.class), mock(SolverScope.class), Arrays.asList(v1, v2),
                 Set.of(e1, e2, e3));
         var rebasedMove = move.rebase(destinationScoreDirector);
@@ -70,26 +70,26 @@ void equality() {
         var e1 = new TestdataListEntity("e1", v1);
         var e2 = new TestdataListEntity("e2");
 
-        var supply = mock(ListVariableStateSupply.class);
-        var move = new ListRuinRecreateMove<TestdataListSolution>(supply,
+        var descriptor = mock(ListVariableDescriptor.class);
+        var move = new ListRuinRecreateMove<TestdataListSolution>(descriptor,
                 mock(RuinRecreateConstructionHeuristicPhaseBuilder.class), mock(SolverScope.class), List.of(e1),
                 Set.of(v1));
-        var sameMove = new ListRuinRecreateMove<TestdataListSolution>(supply,
+        var sameMove = new ListRuinRecreateMove<TestdataListSolution>(descriptor,
                 mock(RuinRecreateConstructionHeuristicPhaseBuilder.class), mock(SolverScope.class), List.of(e1),
                 Set.of(v1));
         assertThat(move).isEqualTo(sameMove);
 
-        var differentMove = new ListRuinRecreateMove<TestdataListSolution>(supply,
+        var differentMove = new ListRuinRecreateMove<TestdataListSolution>(descriptor,
                 mock(RuinRecreateConstructionHeuristicPhaseBuilder.class), mock(SolverScope.class), List.of(e1),
                 Set.of(v2));
         assertThat(move).isNotEqualTo(differentMove);
 
-        var anotherDifferentMove = new ListRuinRecreateMove<TestdataListSolution>(supply,
+        var anotherDifferentMove = new ListRuinRecreateMove<TestdataListSolution>(descriptor,
                 mock(RuinRecreateConstructionHeuristicPhaseBuilder.class), mock(SolverScope.class), List.of(e2),
                 Set.of(v1));
         assertThat(move).isNotEqualTo(anotherDifferentMove);
 
-        var yetAnotherDifferentMove = new ListRuinRecreateMove<TestdataListSolution>(mock(ListVariableStateSupply.class),
+        var yetAnotherDifferentMove = new ListRuinRecreateMove<TestdataListSolution>(mock(ListVariableDescriptor.class),
                 mock(RuinRecreateConstructionHeuristicPhaseBuilder.class), mock(SolverScope.class), List.of(e1),
                 Set.of(v1));
         assertThat(move).isNotEqualTo(yetAnotherDifferentMove);
diff --git a/core/src/test/java/ai/timefold/solver/core/impl/move/director/MoveDirectorTest.java b/core/src/test/java/ai/timefold/solver/core/impl/move/director/MoveDirectorTest.java
index 3aad18a692..5fc485843c 100644
--- a/core/src/test/java/ai/timefold/solver/core/impl/move/director/MoveDirectorTest.java
+++ b/core/src/test/java/ai/timefold/solver/core/impl/move/director/MoveDirectorTest.java
@@ -8,10 +8,21 @@
 import static org.mockito.Mockito.verify;
 import static org.mockito.Mockito.when;
 
+import java.util.Arrays;
+import java.util.List;
+import java.util.Map;
+import java.util.Set;
+
 import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningListVariableMetaModel;
 import ai.timefold.solver.core.impl.domain.solution.descriptor.DefaultPlanningVariableMetaModel;
 import ai.timefold.solver.core.impl.domain.variable.ListVariableStateSupply;
+import ai.timefold.solver.core.impl.domain.variable.descriptor.ListVariableDescriptor;
+import ai.timefold.solver.core.impl.domain.variable.supply.SupplyManager;
+import ai.timefold.solver.core.impl.heuristic.selector.move.generic.RuinRecreateConstructionHeuristicPhase;
+import ai.timefold.solver.core.impl.heuristic.selector.move.generic.RuinRecreateConstructionHeuristicPhaseBuilder;
+import ai.timefold.solver.core.impl.heuristic.selector.move.generic.list.ruin.ListRuinRecreateMove;
 import ai.timefold.solver.core.impl.score.director.InnerScoreDirector;
+import ai.timefold.solver.core.impl.solver.scope.SolverScope;
 import ai.timefold.solver.core.impl.testdata.domain.TestdataEntity;
 import ai.timefold.solver.core.impl.testdata.domain.TestdataSolution;
 import ai.timefold.solver.core.impl.testdata.domain.TestdataValue;
@@ -203,4 +214,75 @@ void updateShadowVariables() {
         verify(mockScoreDirector).triggerVariableListeners();
     }
 
+    @Test
+    void undoNestedPhaseMove() {
+        var innerScoreDirector = mock(InnerScoreDirector.class);
+        var moveDirector = new MoveDirector<TestdataListSolution>(innerScoreDirector);
+        var listVariableStateSupply = mock(ListVariableStateSupply.class);
+        var listVariableDescriptor = mock(ListVariableDescriptor.class);
+        var supplyManager = mock(SupplyManager.class);
+        var ruinRecreateConstructionHeuristicPhaseBuilder = mock(RuinRecreateConstructionHeuristicPhaseBuilder.class);
+        var constructionHeuristicPhase = mock(RuinRecreateConstructionHeuristicPhase.class);
+
+        // The objective is to simulate the reassignment of v1 from e1 to e2
+        // The R&R move analyzes only e1 initially,
+        // since it is impossible to know that v1 will be assigned to e2 during the nested CH phase
+        var v1 = new TestdataListValue("v1");
+        var v2 = new TestdataListValue("v2");
+        var e1 = new TestdataListEntity("e1", v1);
+        var e2 = new TestdataListEntity("e2", v2, v1);
+        var s1 = new TestdataListSolution();
+        s1.setEntityList(List.of(e1, e2));
+        s1.setValueList(List.of(v1, v2));
+        when(innerScoreDirector.getWorkingSolution()).thenReturn(s1);
+        when(innerScoreDirector.isDerived()).thenReturn(false);
+        when(innerScoreDirector.getSupplyManager()).thenReturn(supplyManager);
+        when(supplyManager.demand(any())).thenReturn(listVariableStateSupply);
+        // 1 - v1 is on e1 list
+        // 2 - v1 moves to e2 list
+        when(listVariableStateSupply.getLocationInList(any()))
+                .thenReturn(ElementLocation.of(e1, 0), ElementLocation.of(e2, 1));
+        when(listVariableStateSupply.getSourceVariableDescriptor()).thenReturn(listVariableDescriptor);
+        when(listVariableDescriptor.getFirstUnpinnedIndex(any())).thenReturn(0);
+        when(listVariableDescriptor.getListSize(any())).thenReturn(1);
+        when(listVariableDescriptor.getValue(any())).thenReturn(e1.getValueList(), e2.getValueList());
+        // Ignore the nested phase but simulates v1 moving to e2
+        when(ruinRecreateConstructionHeuristicPhaseBuilder.withElementsToRecreate(any()))
+                .thenReturn(ruinRecreateConstructionHeuristicPhaseBuilder);
+        when(ruinRecreateConstructionHeuristicPhaseBuilder.withElementsToRuin(any()))
+                .thenReturn(ruinRecreateConstructionHeuristicPhaseBuilder);
+        when(ruinRecreateConstructionHeuristicPhaseBuilder.ensureThreadSafe(any()))
+                .thenReturn(ruinRecreateConstructionHeuristicPhaseBuilder);
+        when(ruinRecreateConstructionHeuristicPhaseBuilder.build())
+                .thenReturn(constructionHeuristicPhase);
+        when(constructionHeuristicPhase.getMissingUpdatedElementsMap()).thenReturn(Map.of(e2, List.of(v2)));
+
+        try (var ephemeralMoveDirector = moveDirector.ephemeral()) {
+            var scoreDirector = ephemeralMoveDirector.getScoreDirector();
+            var move = new ListRuinRecreateMove<TestdataListSolution>(listVariableDescriptor,
+                    ruinRecreateConstructionHeuristicPhaseBuilder, mock(SolverScope.class), Arrays.asList(v1), Set.of(e1));
+            move.doMoveOnly(scoreDirector);
+            var undoMove = (RecordedUndoMove<TestdataListSolution>) ephemeralMoveDirector.createUndoMove();
+            // e1 must be analyzed at the beginning of the move execution
+            assertThat(undoMove.getVariableChangeActionList().stream().anyMatch(action -> {
+                if (action instanceof ListVariableBeforeChangeAction<?, ?, ?> beforeChangeAction) {
+                    return beforeChangeAction.entity() == e1 && beforeChangeAction.fromIndex() == 0
+                            && beforeChangeAction.toIndex() == 1 && beforeChangeAction.oldValue().size() == 1
+                            && beforeChangeAction.oldValue().get(0).equals(v1);
+                }
+                return false;
+            })).isTrue();
+            // e2 is not analyzed at the beginning of move execution,
+            // but it must have a before list change event to restore the original elements.
+            assertThat(undoMove.getVariableChangeActionList().stream().anyMatch(action -> {
+                if (action instanceof ListVariableBeforeChangeAction<?, ?, ?> beforeChangeAction) {
+                    return beforeChangeAction.entity() == e2 && beforeChangeAction.fromIndex() == 0
+                            && beforeChangeAction.toIndex() == 1 && beforeChangeAction.oldValue().size() == 1
+                            && beforeChangeAction.oldValue().get(0).equals(v2);
+                }
+                return false;
+            })).isTrue();
+        }
+    }
+
 }