Skip to content

Conversation

@ge0ffrey
Copy link
Contributor

@ge0ffrey ge0ffrey commented Dec 26, 2025

Before

    Constraint speakerUnavailableTimeslot(ConstraintFactory factory) {
        return factory.forEach(Talk.class)
                .join(Speaker.class,
                        filtering((talk, speaker) -> talk.hasSpeaker(speaker)
                                && speaker.getUnavailableTimeslots().contains(talk.getTimeslot())))
                .penalize(...)
                .asConstraint(...);
    }

After (bigO order of magnitude more scalable)

    Constraint speakerUnavailableTimeslot(ConstraintFactory factory) {
        return factory.forEach(Talk.class)
                .join(Speaker.class,
                        contain(Talk::getSpeakers, speaker -> speaker),
                        containedIn(Talk::getTimeslot, Speaker::getUnavailableTimeslots))
                .penalize(...)
                .asConstraint(...);
    }

Refactorings

  • Unify IndexerFactory.buildIndexer() into one switch across the joiner types
    -- Do not create single/composite KeyRetriever in EqualsIndexer and ComparisonIndexer to unify to one constructor.
    -- Extract KeyRetriever creation to IndexerFactory (that class is responsibility for those key tricks)
    -- Implement equal joiner flip() as no-op, so all right bridges indexer can just get a flip.
  • Renamed EqualsIndexer to EqualIndexer because its Joiners.equal() and not Joiners.equals()

Open questions (added as todo's)

  • For Neighberhood, why track a left index?
  • For neighberhoods, why not flip the right bridge?
    I 'd image this code should feel exactly the same as scoring, but not left side
    so always flip basically?

Tasks

  • API
  • javadocs (API, ...)
  • Refactorings to plug it in
  • Unit tests
  • Indexer implementations
  • Use it in Quickstarts (conference scheduling, ...)
  • Docs

@ge0ffrey
Copy link
Contributor Author

Implements part of #8

// (<A, B> becomes <B, A>.)
// TODO Does the requiresRandomAccess check make sense?
// Shouldn't a right bridge always flip, even if there is no left bridge?
// TODO For neighborhoods, why create a left bridge index and keep it up to date at all?
Copy link
Collaborator

Choose a reason for hiding this comment

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

We do not create left index in Neighborhoods. The comment below says that.

// Note that if creating indexer for a right bridge node, the joiner type has to be flipped.
// (<A, B> becomes <B, A>.)
// TODO Does the requiresRandomAccess check make sense?
// Shouldn't a right bridge always flip, even if there is no left bridge?
Copy link
Collaborator

Choose a reason for hiding this comment

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

No. The right bridge doesn't flip for Neighborhoods, because it's queried from the left side. <A, B> never becomes <B, A>, and therefore the index doesn't flip either.

@triceo
Copy link
Collaborator

triceo commented Dec 26, 2025

(bigO order of magnitude more scalable)

In theory, yes. In practice, I've seen hashing overhead kill promising experiments in the past. Have you actually seen this improvement?

@ge0ffrey
Copy link
Contributor Author

(bigO order of magnitude more scalable)

In theory, yes. In practice, I've seen hashing overhead kill promising experiments in the past. Have you actually seen this improvement?

No. We'll need to benchmark it. This PR will allow us to do exactly that.

I even suspect that sometimes you'll want to prefer using filtering(a.contains(b)) over joiners.contain(a, b).
But for the cases where it matters - tags for example - I believe it can be a game changer.

@ge0ffrey
Copy link
Contributor Author

ge0ffrey commented Dec 27, 2025

The tests succeed, but the conference scheduling PR
TimefoldAI/timefold-quickstarts#972

works with this code:

    Constraint talkPrerequisiteTalks(ConstraintFactory factory) {
        return factory.forEach(Talk.class)
                .join(Talk.class,
                        containedIn(talk -> talk, Talk::getPrerequisiteTalks),
                        greaterThan(t -> t.getTimeslot().getEndDateTime(), t -> t.getTimeslot().getStartDateTime()))
                .penalize(...
    }

But fails with this code (containedIn after greaterThan):

    Constraint talkPrerequisiteTalks(ConstraintFactory factory) {
        return factory.forEach(Talk.class)
                .join(Talk.class,
                        greaterThan(t -> t.getTimeslot().getEndDateTime(), t -> t.getTimeslot().getStartDateTime()),
                        containedIn(talk -> talk, Talk::getPrerequisiteTalks))
                .penalize(...
    }

The exception:

java.lang.IllegalStateException: Impossible state: the tuple (ai.timefold.solver.core.impl.util.CompositeListEntry@614ca71c) with composite key (BiCompositeKey[propertyA=2025-12-27T11:30, propertyB=[]]) doesn't exist in the indexer size = 4.
        at ai.timefold.solver.core.impl.bavet.common.index.ComparisonIndexer.getDownstreamIndexer(ComparisonIndexer.java:73)
        at ai.timefold.solver.core.impl.bavet.common.index.ComparisonIndexer.remove(ComparisonIndexer.java:61)
        at ai.timefold.solver.core.impl.bavet.common.AbstractIndexedJoinNode.updateRight(AbstractIndexedJoinNode.java:148)
        at ai.timefold.solver.core.impl.bavet.common.AbstractIndexedJoinNode.updateRight(AbstractIndexedJoinNode.java:22)
        at ai.timefold.solver.core.impl.bavet.common.tuple.RightTupleLifecycleImpl.update(RightTupleLifecycleImpl.java:20)
        at ai.timefold.solver.core.impl.bavet.common.tuple.AggregatedTupleLifecycle.update(AggregatedTupleLifecycle.java:25)
        at ai.timefold.solver.core.impl.bavet.common.StaticPropagationQueue.processAndClear(StaticPropagationQueue.java:115)
        at ai.timefold.solver.core.impl.bavet.common.StaticPropagationQueue.propagateUpdates(StaticPropagationQueue.java:94)
        at ai.timefold.solver.core.impl.bavet.NodeNetwork.settleLayer(NodeNetwork.java:65)
        at ai.timefold.solver.core.impl.bavet.NodeNetwork.settle(NodeNetwork.java:52)
        at ai.timefold.solver.core.impl.bavet.AbstractSession.settle(AbstractSession.java:63)

Notice how it's the ComparisonIndexer that cannot find the Contain(edIn)Indexer during removal of a talk.

@sonarqubecloud
Copy link

@ge0ffrey
Copy link
Contributor Author

ge0ffrey commented Jan 2, 2026

The conference scheduling dataset with 15 talks does not show a move speed evaluation improvement when two constraints are replaced to use contain() and containedIn().

It's a scalability improvement, not a performance improvement, so I presume we need to benchmark with far bigger datasets to confirm it's usefullness.

@ge0ffrey
Copy link
Contributor Author

ge0ffrey commented Jan 2, 2026

School timetabling scheduling POC benchmark of contains vs filtering
From 9236/sec to 22056/sec on only 100 entities.

Before

Constraint softTagOld(ConstraintFactory constraintFactory) {
    // A teacher prefers to teach sequential lessons and dislikes gaps between lessons.
    return constraintFactory
            .forEach(Lesson.class)
            .join(Lesson.class, Joiners.filtering((a, b) -> a.getTags().contains(b.getReqTag())))
            .reward(HardSoftScore.ONE_SOFT)
            .asConstraint("Soft tag");
}

2026-01-02 17:31:42,151 INFO Problem scale: entity count (100), variable count (200), approximate value count (31), approximate problem scale (4.065119 × 10^217).
2026-01-02 17:31:42,416 INFO Construction Heuristic phase (0) ended: time spent (267), best score (-6hard/5946soft), move evaluation speed (56818/sec), step total (100).
2026-01-02 17:32:12,154 INFO Local Search phase (1) ended: time spent (30005), best score (-4hard/5946soft), move evaluation speed (8815/sec), step total (41832).
2026-01-02 17:32:12,156 INFO Solving ended: time spent (30007), best score (-4hard/5946soft), move evaluation speed (9236/sec), phase total (2), environment mode (PHASE_ASSERT), move thread count (NONE).

After

Constraint softTagNew(ConstraintFactory constraintFactory) {
    // A teacher prefers to teach sequential lessons and dislikes gaps between lessons.
    return constraintFactory
            .forEach(Lesson.class)
            .join(Lesson.class, Joiners.contain(Lesson::getTags, Lesson::getReqTag))
            .reward(HardSoftScore.ONE_SOFT)
            .asConstraint("Soft tag");
}

2026-01-02 17:32:46,053 INFO Problem scale: entity count (100), variable count (200), approximate value count (31), approximate problem scale (4.065119 × 10^217).
2026-01-02 17:32:46,284 INFO Construction Heuristic phase (0) ended: time spent (233), best score (-6hard/5946soft), move evaluation speed (65217/sec), step total (100).
2026-01-02 17:33:16,057 INFO Local Search phase (1) ended: time spent (30006), best score (-4hard/5946soft), move evaluation speed (21727/sec), step total (103137).
2026-01-02 17:33:16,059 INFO Solving ended: time spent (30008), best score (-4hard/5946soft), move evaluation speed (22056/sec), phase total (2), environment mode (PHASE_ASSERT), move thread count (NONE).

Code: TimefoldAI/timefold-quickstarts#978

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