Skip to content

refactor: introduce incremental constraint collectors for improved performance#2270

Open
triceo wants to merge 21 commits into
TimefoldAI:mainfrom
triceo:collector-update
Open

refactor: introduce incremental constraint collectors for improved performance#2270
triceo wants to merge 21 commits into
TimefoldAI:mainfrom
triceo:collector-update

Conversation

@triceo
Copy link
Copy Markdown
Collaborator

@triceo triceo commented Apr 29, 2026

This benchmark run proves there are no regressions from adding the incremental support to the GroupNode:
https://github.com/TimefoldAI/timefold-solver-benchmarks/actions/runs/25095715799/job/73533079940

This benchmark proves no regressions after the new collector implementations were integrated:
https://github.com/TimefoldAI/timefold-solver-benchmarks/actions/runs/25427319378

The most expensive collectors are not actually part of those benchmarks, so the biggest benefit of this PR does not show up there.

@triceo triceo force-pushed the collector-update branch from 3e5d3e0 to a69c7c2 Compare April 29, 2026 15:27
@triceo triceo force-pushed the collector-update branch from a69c7c2 to c4eaa81 Compare May 1, 2026 16:44
@triceo triceo force-pushed the collector-update branch from bbe0f3b to 6d3b0d3 Compare May 2, 2026 13:14
@triceo triceo force-pushed the collector-update branch from 6d3b0d3 to e151bc3 Compare May 2, 2026 16:57
@triceo triceo force-pushed the collector-update branch from e151bc3 to 42621e9 Compare May 6, 2026 09:26
@triceo triceo force-pushed the collector-update branch from f67e6ae to 2e21a87 Compare May 6, 2026 18:12
@triceo triceo marked this pull request as ready for review May 6, 2026 18:12
Copilot AI review requested due to automatic review settings May 6, 2026 18:12
@triceo
Copy link
Copy Markdown
Collaborator Author

triceo commented May 6, 2026

@Christopher-Chianelli Ready for review now.

I wasn't able to keep the uni changes separate from the bi/tri/quad, so this PR is large. But if you review the uni parts, you can just skim the rest; they are mechanical copies with updates for the higher arities.

@triceo triceo added this to the v2.1.0 milestone May 6, 2026
@triceo
Copy link
Copy Markdown
Collaborator Author

triceo commented May 6, 2026

It would be great if we had update() variants for consecutive sequences and connected intervals; those collectors are expensive - if we can save some work there, it will immediately show.

@triceo triceo self-assigned this May 6, 2026
@Christopher-Chianelli
Copy link
Copy Markdown
Contributor

Christopher-Chianelli commented May 6, 2026

@triceo GitHub still not allowing me to create any review comments, so I putting my review comments directly on the the PR.

UniConstraintCollector - Does a user need to implement both accumulator and incrementalAccumulator? I notice some of our builtin ConstraintCollectors (such as AndThenUniCollector) implement both?

@Christopher-Chianelli
Copy link
Copy Markdown
Contributor

Christopher-Chianelli commented May 6, 2026

  • Per the Javadoc of ...AccumulatedValue:
/**
 * <li>{@link #add(Object)} will be called externally exactly once, when the value enters the group.
 * An instance of {@link UniConstraintCollectorAccumulatedValue} will only be created if there is a value to add.</li>
 * /
  • But in ConditionalUniCollector:
        private final UniConstraintCollectorAccumulatedValue<A> innerValue;
        private boolean active = false;

        AccumulatedValue(ResultContainer_ container) {
            this.innerValue = innerIncremental.intoGroup(container);
        }

        @Override
        public void add(A a) {
            if (!predicate.test(a)) {
                return;
            }
            active = true;
            innerValue.add(a);
        }

        @Override
        public void update(A a) {
            boolean nowActive = predicate.test(a);
            if (active && nowActive) {
                innerValue.update(a);
            } else if (active) {
                active = false;
                innerValue.remove();
            } else if (nowActive) {
                active = true;
                innerValue.add(a);
            }
        }

i.e. add and remove can both be called multiple times.

@triceo
Copy link
Copy Markdown
Collaborator Author

triceo commented May 6, 2026

i.e. add and remove can both be called multiple times.

They can, but they will not be. That is a contract that we guarantee.
(The alternative was to create more garbage, essentially add() would create a new container - and that was negatively affecting benchmarks.)

@Christopher-Chianelli
Copy link
Copy Markdown
Contributor

i.e. add and remove can both be called multiple times.

They can, but they will not be. That is a contract that we guarantee. (The alternative was to create more garbage, essentially add() would create a new container - and that was negatively affecting benchmarks.)

Something like this would cause add/remove to be called multiple times currently:

constraintFactory.forEach(Employee.class)
    .join(Shift.class, equal(id(), Shift::getEmployee)
    // Employee has an InverseCollectionShadowVariable to get shift count
    .groupBy((employee, shift) -> employee, conditionally((employee, shift) -> employee.getShiftCount() > 5, sum((employee, shift) -> shift.getDuration())))
    .filter((employee, hours) -> hours != null && hours > 24)
    .penalize(HardSoftScore.ONE_HARD, (employee, hours) -> hours - 24)
    .asConstraint("Overworked employee")

In particular, the conditionally can and will call the sum's add multiple times. It seems our actually contract is:

  • add can only be called when the value is not "added" (and once called, the value is "added")
  • update can only be called when the value is "added" (and does not affect the state of "added")
  • remove can only be called when the value is "added" (and once called, the value is not "added")

i.e. this is explictly allowed:

var a = new Data();
value.add(a);
a.change();
value.update(a);
value.remove(a);
value.add(a);

@triceo
Copy link
Copy Markdown
Collaborator Author

triceo commented May 11, 2026

Something like this would cause add/remove to be called multiple times currently:

Good point. For collectors which contain other collectors (and only for them), the contract as specified doesn't actually hold.

Two ways out of this:

  • These nesting collectors would correctly recreate the inner instances on every remove+add, so that the contract still holds.
  • Or we update the contract to what you outlined in your previous message.

Since we want to prevent unnecessary garbage from being collected, the second option is probably the way to go.

triceo and others added 20 commits May 18, 2026 09:57
# Conflicts:
#	core/src/main/java/ai/timefold/solver/core/impl/score/stream/collector/ListUndoableActionable.java
…nstraintCollector

*ConstraintCollectorAccumulator now extends the corresponding function type
(BiFunction/TriFunction/QuadFunction/PentaFunction) and throws on apply().
Bavet nodes detect incremental via instanceof in toIncremental() instead of
isIncremental(). fromIncremental() deleted; all built-in impls return the
accumulator type directly as a covariant override of accumulator().

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
@triceo
Copy link
Copy Markdown
Collaborator Author

triceo commented May 18, 2026

@Christopher-Chianelli This is the finalized code, with the incremental switches completely removed.

Please review if you find some time between Team Days and your PTO, as I'd like to merge this week. Do not work on this during Team Days, please - if you don't review before your PTO, I will handle this with Fred instead.

@sonarqubecloud
Copy link
Copy Markdown

Quality Gate Failed Quality Gate failed

Failed conditions
20.4% Duplication on New Code (required ≤ 5%)

See analysis details on SonarQube Cloud

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