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
left a comment
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; |
There was a problem hiding this comment.
I wouldn't do this; I would instead do if (key == null) {updateChanged(changedEntity);} else if (...) {} below
There was a problem hiding this comment.
It's still useful for unassigned element to avoid updating twice an unassigned element (since its entity and previous both change and each fires its own event).
So, I have added another map processedUnassignedEntitySet.
|
@fodzal Thanks for the fix! Do you have any plan for when you expect to undraft this? |
| updateChanged(changedEntity); | ||
| } | ||
| } else { | ||
| var lastProcessed = keyToLastProcessedObject.get(key); |
There was a problem hiding this comment.
Any chance we can use some sort of sentinel for the null key, and therefore avoiding the second map? Since the map is an identity hash map, we can probably use almost anything that's guaranteed not to be there already - such as some UUID.
There was a problem hiding this comment.
This may be where I run against Chris' previous comment. If that's the case, then I'd argue the solution is worse than the problem, and the sentinel works.
There was a problem hiding this comment.
Yes, I guess we can keep my first version (and add a comment in the code to explain the logic)
There was a problem hiding this comment.
You both do realize changedEntities can be turned into an identity set to avoid duplicates? The hash check would either be here (inside the map) or in afterVariableChanged, and doing it in afterVariableChanged would minimize the size of changedEntities.
There was a problem hiding this comment.
It doesn't help if we're both looking into this. My bad - I'll remove myself from the conversation.
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 is tracked by its own identity instead of being pooled into
keyToLastProcessedObjectunder the sharednullkey:A dedicated
processedUnassignedEntitySet(identity-based, cleared every pass likekeyToLastProcessedObject) dedupes the two events one unassigned element fires (entityandpreviousboth changing), without pooling it into the map meant for chain-position lookups.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 four 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.