Conversation
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
requested changes
Sep 18, 2026
Christopher-Chianelli
left a comment
Contributor
There was a problem hiding this comment.
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; |
Contributor
There was a problem hiding this comment.
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); |
Contributor
There was a problem hiding this comment.
missing afterListVariableElementUnassigned calls for value2 and value3
Collaborator
|
@fodzal Thanks for the fix! Do you have any plan for when you expect to undraft this? |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Issue.
In a
SINGLE_DIRECTIONAL_PARENTgraph, a pass that unassigns two or more elements only updates the first ofthem; 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:The key is
nullfor every unassigned element, and they all compare equal too (getIndexOrElsedefaults to 0) — so the first one walked claims thenullkey, 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:compare(v2, v3) == 0, not< 0Any 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:
The guard still dedupes the two change events one unassigned element fires (its
entityandpreviousboth 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'sgetInverseSingleton; the record's Javadoc now says what each component returns for an element in no chain.Test.
SingleDirectionalParentVariableReferenceGraphTest#elementsUnassignedInTheSamePassAreAllUpdatedIt 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#countSupplierthrows if called twice in the same pass, which pins that the fix doesn't trade a skipped update for a doubled one.