Skip to content

perf: represent list entity's elements by a single chain node in the declarative graph - #2725

Draft
fodzal wants to merge 11 commits into
TimefoldAI:mainfrom
fodzal:perf/list-chain-node
Draft

fodzal wants to merge 11 commits into
TimefoldAI:mainfrom
fodzal:perf/list-chain-node

Conversation

@fodzal

@fodzal fodzal commented Oct 5, 2026

Copy link
Copy Markdown
Contributor

New version of this PR

This PR replaces the list element cascade (#2548). The detected
models are the same; the update is new.

  • Faster: (see Benchmark).
  • Cleaner: the cascade updated the list entities' graph, then walked the dirty chains, then ran the
    graph again, until nothing changed; a notifier wrapper flagged the chains to re-walk, and a post-chain
    variable could be computed twice. Here a chain is a node of the graph, so a single topological pass
    computes every affected supplier once.

The production code is a bit bigger than for the former PR; I think it's better like this but let me know if you prefer the another one!

Why

Follow-up to #2357 and #2506. A planning list variable model with declarative variables on both the list
entity and its elements is classified ARBITRARY: for example a vehicle whose end time is sourced from its
visits ("visits[].endServiceTime"), or a multi-trip model where a trip starts where the previous one ends.
The arbitrary graph then keeps a node per element and variable, adds and removes edges on every list
change and recomputes its topological order. The elements outnumber the other entities by far, yet their
dependencies need no graph: an element only reads its chain, itself and its list entity.

What

A generalization of SINGLE_DIRECTIONAL_PARENT: the walk of the elements along their list is taken out of
the graph when the model allows it, and the rest of the graph stays as it is. Each list entity gets a single
chain node for its elements, ordered after the list entity's variables its elements read (pre-chain,
vehicle.startTime) and before the ones sourced from its elements (post-chain,
"visits[].endServiceTime"):

A.startTime ──▶ [chain A: a1 → a2 → … → aN] ──▶ A.endTime ──▶ B.startTime ──▶ [chain B] ──▶ B.endTime

The graph shrinks from O(elements) to O(list entities) nodes and loses all per-element edges. In the
measured models no dynamic edge is left, so it is a FixedVariableReferenceGraph.

How

Detection, in two steps.

  1. Shape, in GraphStructure (new structure LIST_CHAIN): the elements have a single
    previous/next parent, and their sources stay in their chain, on themselves, or on a declarative or
    genuine variable of their list entity reached through their inverse; the other classes reach the elements only
    through "visits[].x"; the list entity has declarative variables; no alignment key. Anything else keeps
    today's structure.
  2. Loops, at build time. The build tests the chain edges
    against the fixed edges, and falls back to ARBITRARY when they close a loop. A genuine fixed loop still
    fails fast, in the arbitrary graph's build, with its usual message.

Without a score director or without any list entity, the structure is built as ARBITRARY too.

Graph construction.

  • The inner graph: every variable but the elements', one node per variable, built by the existing
    machinery. The list element locators are skipped, so no per-element edge is ever added.
  • The chain nodes: one per list entity, with fixed edges from its declarative pre-chain variables and to its
    post-chain variables. A genuine pre-chain variable (vehicle.departureTime, a planning variable) has no
    node: its change marks the chain node directly. VariableUpdater is extracted from VariableUpdaterInfo
    so that a node can carry the chain updater.

Graph traversal.

  • The graph: a single updateChanged() pass in topological order, as today. Beforehand, a thin wrapper
    records the elements whose sources changed, classifies them by list entity (as list indexes), and marks
    those chain nodes changed.
  • A chain node, when the pass reaches it, walks its chain the way SINGLE_DIRECTIONAL_PARENT does: from each
    changed element, in chain order, until an element comes out unchanged. It walks the whole chain when a
    pre-chain value changed (any element may read it) or when the list changed without any element recording
    itself (the forced update of FULL_ASSERT). Its return value propagates to the post-chain variables
    through the graph's edges.
  • A looped list entity sets its elements to null and inconsistent, and the next walk of the chain recovers
    them. getVariableLoops() reports the elements, as the arbitrary graph does.

Note: SINGLE_DIRECTIONAL_PARENT

SINGLE_DIRECTIONAL_PARENT could be replaced by this graph: routing its models to chain nodes measured
11 % to 29 % faster. Most of the gain comes from walking int list indexes, following #2660, rather than
following and sorting elements through the list variable state, which SINGLE_DIRECTIONAL_PARENT could
adopt directly. This is optional, so SINGLE_DIRECTIONAL_PARENT is left unchanged here.

Possible follow-ups

Other shapes can be covered with little code, for example elements whose sources are variables of another
list entity than their own. Or groups (alignment key) which stays in the same list entity.

Design choices

  • The chain updater's node group key is the list variable's metamodel, not a reserved group id.
    VariableReferenceGraphBuilder.addVariableReferenceEntity creates one node per group key and entity,
    and the other updaters' keys are the int group ids that getGroupVariableUpdaterInfoMap allocates
    counting up from zero. A reserved id such as -1 works, but only as long as that allocation never
    produces it: an invariant held by another method, which a change there would break silently, by merging
    a chain node into another updater's node. A nullable id, meaning "no group", was also tried; it adds
    null branches to the shared builder. The metamodel cannot collide with an Integer, whatever the ids
    are, and it is already the chain node's id(), the key it is looked up by. The cost is the builder's
    map, now keyed by Object rather than Integer.

Testing

  • A seeded randomized differential test (30 seeds × 40 moves: list moves of every kind in both directions,
    departure time changes) and a FULL_ASSERT solve with every list move, both against an explicitly
    arbitrary recomputation.
  • Supplier call counts: every affected supplier exactly once per update; a swap leaves the unchanged
    elements between its changes alone; unassigned elements are computed once.
  • A genuine pre-chain variable: its change alone, and together with a list change in one update, walks
    the whole route.
  • A second working solution, as every solve sets, while the list entity reports its consistency through a
    pre-chain variable.
  • Dependency loops through routes: detection, recovery, elements leaving a looped vehicle, reported loops
    equal to the arbitrary graph's; an update given up on a loop, then another move without undo.
  • GraphStructureTest: every accepted shape and every rejection; the build-time fallback and fail-fast.
  • The existing ListElementShadowVariableTest (feat: support sourcing declarative shadow variables from planning list variable elements #2506) runs on the chain graph unchanged.

Note: DeclarativeShadowVariableAssertions

This PR adds a test helper, used by the chain tests only: a fixed point assertion, a random list move
generator, and a FULL_ASSERT solve with every list move. The assertion recomputes the declarative
variables with an explicitly ARBITRARY graph and compares them to the incremental ones.
FULL_ASSERT recomputes with the chain graph itself, so its reference depends on the code under test;
the ARBITRARY graph does not.

Benchmark

Local harness, not part of this PR: a fresh JVM per arm and scenario, a discarded warm-up repetition, the
same seed and move count limit on every arm; a checksum over every shadow variable is identical across
arms. Arms: main (14249b71d7), the cascade (this PR's previous head, 3a85ed155d), and this PR.

Scenario Model main moves/s Cascade moves/s This PR moves/s vs cascade vs main
pairedTrips 116 technician pairs × 5 trips, ~7,000 visits, time windows 22 22,115 25,171 +14 % (noise) ×1,144
pairedTripsSmall 30 pairs × 5 trips × 12 visits 328 29,528 43,197 +46 % ×132
tripChains 60 technicians × 3 consecutive trips × 15 visits 192 25,152 32,158 +28 % ×167
manyOwners 600 independent trips × 4 visits 216 97,238 107,434 +10 % (noise) ×497
longRoutesWindows 2 technicians × 3 trips × 60 visits 1,334 11,470 16,523 +44 % ×12.4
classicAggregate Classic VRPTW, 50 vehicles × 20 visits, end time from visits 724 55,439 73,584 +33 % ×102
SINGLE_DIRECTIONAL_PARENT, non-list Not affected — — — noise noise

fodzal and others added 11 commits September 24, 2026 18:07
…in node in the declarative graph

A planning list variable model with declarative variables on both the list entity and its
elements is classified ARBITRARY: for example a vehicle whose end time is sourced from its
visits, or a multi-trip model where a trip starts where the previous one ends. The arbitrary
graph keeps a node per element and variable, adds and removes edges on every list change and
recomputes its topological order, even though an element only ever reads its chain, itself
and its list entity.

This generalizes SINGLE_DIRECTIONAL_PARENT: the walk of the elements along their list is
taken out of the graph when the model allows it, and the rest of the graph stays as it is.
Each list entity gets a single chain node for its elements, ordered after the list entity's
variables its elements read (pre-chain) and before the ones sourced from its elements
(post-chain).

Detection happens in two steps: a shape check in GraphStructure (new structure LIST_CHAIN),
then a build-time loop check that falls back to ARBITRARY when the chain node's
overapproximated edges would close a loop the arbitrary graph would not. Graph construction
skips the list element locators entirely, so no per-element edge is ever added; the chain
node carries fixed edges from its declarative pre-chain variables and to its post-chain
variables.

Ports the multi-entity chain domains and tests: fact-collection and plain-fact vehicle
chains, a next-directional model, rebasing elements, non-contiguous dirty elements, fixed
loop fail-fast, and fallback models. A seeded randomized differential test and a call-count
test (every affected supplier recomputed exactly once per update) run against an explicit
ARBITRARY recomputation.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
…s changed elements, consolidate its test coverage

Leaves the shared declarative graph code as it was wherever the chain node does not
need it, and documents what the chain node's comments rely on.

Strengthens the event coverage (the chain node count test now sends the same list
change events a real move sends), removes list chain tests that other tests already
cover (fallback domains, the next-directional solve the differential test already
covers, duplicated test cases), and names the remaining test domains after their
package and what they test.
…re detection, track its changes in the graph

Folds the chain detection into the existing GraphStructure detection instead of a
separate pass, and shares one updater list among the chain nodes instead of each
owning its own. Finds the unassigned elements an update recomputed in constant time,
and tracks the chain's changes in the graph itself rather than in the updater.
A pre-chain variable can be a genuine (planning) variable of the list entity rather
than a declarative one, for example a vehicle's departure time: its change alone
marks the chain node directly, with no node of its own, so the elements that read it
through their inverse no longer need a separate domain and test class.

Builds the arbitrary graph the fixed point assertion compares against once per
solution class rather than once per test, since the solution descriptor is the
expensive part of the comparison.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
The node walks a list entity's chain, which the pre-chain and post-chain variables
already refer to; one term for both reads better than two.

Adds the chain node's edges to the builder before testing them for a loop, returns
the list chain structure from its detection instead of a boolean, and clears a
looped chain's elements in list order. Covers the genuine pre-chain variable with
the canonical list chain domain.

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

Hoists the owner-to-chain-node map out at construction, marks dirty chain nodes once
per owner after classification, and records a changed range only through the after
list-variable event, halving the classification work. The chain node walks its
elements by list index instead of through the list variable state, and recomputes
each unassigned element once per update.

Detects a pre-chain change by value instead of by notifier, and leaves the loop
decision (fail-fast vs. fall back to ARBITRARY) to the build rather than the
updater. A dependency loop that an update gives up on keeps its dirty chains, and
walking the whole chain of a list entity that leaves such a loop reports every
element the loop runs through, matching the arbitrary graph.

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

Setting a new working solution builds its graph while the previous one still
receives the events, among them the consistency of the new list entities,
reported through their first declarative variable, which may be a pre-chain one.
Every solve sets two working solutions, so it failed on such a model.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Declares that a list chain state lookup may find none instead of assuming it always
does. Keeps the single directional parent's early termination check as upstream: the
chain node gets its own check, which ignores the inverse sources, since a change of
the pre-chain variable they read must walk the whole chain. Derives the chain
element class from the directional parent's metamodel instead of carrying it as a
separate, often-null component.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Keep the chain node in its list chain state instead of a second map,
drop the list entity class check the chain state lookup already makes,
update the list chain graph without a finally, as the graphs it wraps,
and walk a new list chain whole through its stale flag instead of a sentinel.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
@triceo

triceo commented Oct 6, 2026 •

Copy link
Copy Markdown
Collaborator

@fodzal Thanks for the PR! This is quite a sizable one, here's how I propose we deal with it:

  • First, we extract some bits from it which are independent of the larger change. For example, I already have a branch of my own (created independently of your work, coincidentally at the same time) which replaces the list var state checking for previous/next with a direct list iteration, just like you do here. I will open that PR and we will get it merged first; that is a nice, independent +50 % in some of my own measurements.
  • Once we reduce this PR to the core, @Christopher-Chianelli will do the review and work with you to get it merged.
  • We may decide we do not want some parts of this, but let's talk about that if/when we get there.

Unfortunately, you picked a part of the codebase which is undergoing changes relatively often; for example, I already have a PR out which is part perf improvement, part bugfix. Due to its bugfix nature, it will be merged soon, before your PR and therefore causing you merge conflicts.

The only way how I see us avoiding these issues going forward is to discuss what our plans are and what your plans are, and figuring out a way to not interfere with each other's work too much. I am open to that conversation.

@fodzal

fodzal commented Oct 9, 2026

Copy link
Copy Markdown
Contributor Author

Thanks @triceo, that works for me.

  • I'll wait for 2735 to be merged, then adapt my code to it (and to 2722). Should be ok, I will see if I can re-use parts of 2735.
  • I'll reduce the PR to its core, by enabling the chain node only in more specific cases (the rest of the graph is fixed, the elements read only declarative variables of their list entity). Everything else keeps today's ARBITRARY graph.
    -> It will reduce a bit the size of this PR but it's difficult to explode in small PRs (I can maybe extract VariableUpdater from VariableUpdaterInfo, but it represents not so many code and it's useless without the final PR)
  • I'll also cut down the tests to the essential ones. (For now, there are 1000 lines of production and 3600 of tests...)
  • I suggest to create another separate and small PR with the new test helper DeclarativeShadowVariableAssertions (that I have developped in this PR).
    -> It recomputes the declarative shadow variables with a forced ARBITRARY graph and compares them
    with the incremental ones, so it checks that the specialized graphs (SINGLE_DIRECTIONAL_PARENT, …)
    compute the same values. (As it doesn't work with FULL_ASSERT).
    Do you think it would be useful?
  • Yes, next time, I will anticipate and tell you on what I am working on (if it's big). It was initially only a cleaner version of perf: update planning list variable elements by a cascade instead of the graph #2548. (For now, I have no other plans outside of this PR).

@fodzal
fodzal marked this pull request as draft October 9, 2026 15:02
@triceo

triceo commented Oct 14, 2026

Copy link
Copy Markdown
Collaborator

I have merged the second-to-last change to shadows that I had planned. I have also submitted the final PR where I intend to change that area:

#2750

So, after that, this PR can be safely rebased and finished.

This branch is waiting to be deployed

1 waiting deployment
external — f68af08c Waiting Oct 5, 2026 by fodzal via approval_required #4967
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.

2 participants