Skip to content

fix: update every element unassigned in the same shadow variable pass - #2674

Draft
fodzal wants to merge 6 commits into
TimefoldAI:mainfrom
fodzal:fix/single-directional-unassigned-skip
Draft

fodzal wants to merge 6 commits into
TimefoldAI:mainfrom
fodzal:fix/single-directional-unassigned-skip

Conversation

@fodzal

@fodzal fodzal commented Sep 18, 2026

Copy link
Copy Markdown
Contributor

Issue.
In a SINGLE_DIRECTIONAL_PARENT graph, a pass that unassigns two or more elements only updates the first of
them; the rest keep the value they held while still assigned.

updateChanged() walks each changed element's chain, and skips one that a previous walk already covered:

var key = keyFunction.apply(changedEntity);   // the element's inverse entity
var lastProcessed = keyToLastProcessedObject.get(key);
if (lastProcessed == null || topologicalOrderComparator.compare(lastProcessed, changedEntity) < 0) {
    lastProcessed = updateChanged(changedEntity);
    keyToLastProcessedObject.put(key, lastProcessed);
}

The key is null for every unassigned element, and they all compare equal too (getIndexOrElse defaults to 0) — so the first one walked claims the null key, and every other unassigned element wrongly reads that walk as already covering it, even though a walk starting at an unassigned element never reaches past itself.

[v1, v2, v3] (counts 0, 1, 2), one move unassigning both v2 and v3:

key guard outcome
v2 null nothing under that key yet walked, recomputed to null ✓
v3 null compare(v2, v3) == 0, not < 0 skipped, keeps its stale count of 2

Any move that unassigns 2 or more elements of the same list under one before/after bracket triggers this (for example: SubListUnassignMoveProvider).

Fix.
An unassigned element stands as its own key:

var entityKey = keyFunction.apply(changedEntity);
var key = entityKey != null ? entityKey : changedEntity;

The guard still dedupes the two change events one unassigned element fires (its entity and previous both change) — they share the same object as their key — while no longer pooling different unassigned elements together.

TopologicalSorter.key() was declared non-nullable even though it's getInverseSingleton; the record's Javadoc now says what each component returns for an element in no chain.

Test.
SingleDirectionalParentVariableReferenceGraphTest#elementsUnassignedInTheSamePassAreAllUpdated
It unassigns two of an entity's three elements in one pass, firing both parent-variable changes for each (as a real unassign does), and asserts both lose their count. TestdataCountingValue#countSupplier throws if called twice in the same pass, which pins that the fix doesn't trade a skipped update for a doubled one.

fodzal and others added 6 commits September 18, 2026 09:35
A single directional parent graph walks the chain of each changed element, and
keys those walks by the chain the element belongs to, so that a changed element
a previous walk already covered is not walked again. An unassigned element
belongs to no chain: the key function gives back null for every one of them,
pooling them under a single key, and they share a topological order too. The
guard therefore read each unassigned element after the first as already covered
by a walk that could not have reached it, since an unassigned element has no
next element and its own walk covers nothing but itself.

A pass that unassigned several elements at once, as ruin and recreate does when
it removes its batch, left all but one of them carrying the values of the list
they had left.

An unassigned element now stands as its own key, which both keeps the elements
apart and still drops the second record of one of them; both of an element's
parent variables change when it leaves a list.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SingleDirectionalParentVariableReferenceGraphTest exercises the fix at the
graph's own API, feeding it the events a real unassign fires. This adds an
end-to-end regression through the public Move API instead, on a purpose-built
list domain whose arrivalTime is sourced from previous alone.

It unassigns two visits in one range-based move - the shape
SelectorBasedListRuinRecreateMove uses for its whole ruined batch - then
reassigns only one of them in a later, separate move, leaving the other
unassigned for good. Confirmed to fail on the pre-fix graph (v3 stuck at its
stale arrivalTime of 30) and pass with the fix.

A composed pair of single-element unassigns does not reproduce the bug:
MoveDirector runs its own shadow variable update after every individual
primitive call, so two of them still make two passes with one element
unassigned each, never two at once. Only a primitive that unassigns several
elements under one before/after bracket puts them in the same pass, which is
what the real ruin move relies on and what this test now uses.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…adoc

The class javadoc still described the ruin as done "with custom moves", left over
from an earlier iteration that composed two single-element unassigns (which,
as the javadoc right below it already explained, does not even reproduce the
bug). Moves.unassign(variableMetaModel, Range) is not custom: it is
SubListUnassignMove, the same move class SubListUnassignMoveProvider draws
from as a real neighborhood, and that SubListChangeMoveProvider also produces
whenever its crossingNull targets an unassigned destination.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
The single test and its javadoc framed ruin and recreate as the trigger,
but composing a bare Moves.unassign(variableMetaModel, Range) - SubListUnassignMove,
which SubListUnassignMoveProvider draws from as an ordinary standalone
neighborhood - already reproduces the bug on its own, with nothing else needed.

Split into two: unassigningTwoVisitsAtOnceClearsBothArrivalTimes is the minimal
repro (one move, two elements, both must lose their arrivalTime), confirmed to
fail on the pre-fix graph by itself. reassigningOneOfTwoUnassignedVisitsRecomputesItFresh
keeps the richer ruin-then-reassign scenario as a second, separate check, with
SelectorBasedListRuinRecreateMove demoted to "one other example that also hits
this" rather than the reason it matters.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
SingleDirectionalParentVariableReferenceGraphTest#elementsUnassignedInTheSamePassAreAllUpdated
already regresses the bug directly against the graph's own contract, matching
that file's own pre-existing test style (supplierMethodsAreOnlyCalledOnce uses
the same mocked ListVariableState approach). The end-to-end test added a
second, real-move-driven angle on the same bug, but on reflection one test is
enough here - unlike FixedVariableReferenceGraph's sibling bug, which has no
dedicated unit test file of its own and relies solely on a MoveTester-driven
test, SingleDirectionalParentVariableReferenceGraph already had one to extend.

Drops SingleDirectionalUnassignedShadowVariableTest and the
testdomain/shadow/single_directional_unassign domain built only for it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>

@Christopher-Chianelli Christopher-Chianelli left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I would do it a different way to avoid putting useless keys inside the processed object map.

// instead keeps unassigned elements apart: sharing the null key would pool them all
// together, and since they also compare equal, the guard below would treat the first
// one walked as already covering the rest.
var key = entityKey != null ? entityKey : changedEntity;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I wouldn't do this; I would instead do if (key == null) {updateChanged(changedEntity);} else if (...) {} below


// Both of an unassigned element's parent variables change, so each element is recorded twice.
// TestdataCountingValue.countSupplier fails the test if that makes it update twice.
graph.afterVariableChanged(entityVariableMetamodel, value2);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

missing afterListVariableElementUnassigned calls for value2 and value3

@triceo

triceo commented Sep 20, 2026

Copy link
Copy Markdown
Collaborator

@fodzal Thanks for the fix! Do you have any plan for when you expect to undraft this?
We have a release coming up and, if possible, we'd like to roll this one in.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants