Search code examples
javaoptaplanner

Optaplanner: manual grouping / group into multiple groups


I have the following dataset, and am trying to work out how to best write a specific constraint.

My PlanningEntity looks (roughly) as follows:

@PlanningEntity
public class Participation {
    @PlanningId
    private long id;

    private Student student;
    private Lesson lesson;

    @PlanningVariable(valueRangeProviderRefs = "possibleEnrollments")
    private Boolean enrolled;
}

whereby a Lesson has a public List<Subject> getSubjects() (note: a list of multiple subjects).

What I would like to do in my penalize method is look at all participations of a student/subject (single subject!) combination. In other words, if I have lesson A with subject 1, lesson B with subject 2 and lesson C with subjects 1 and 2, I would like to do a grouping in such a way that in my penalize function I get two (Student, List<Participation>) callbacks: one for subject 1 and one for subject 2, whereby the first lists contains lessons A and C, and the second list has lessons B and C. So, C is contained in two lists.

The following does not work:

constraintFactory
                .forEach(Participation.class)
                .groupBy(Participation::getStudent, Participation::getSubjects, toList())

since this groups on the entire List returned by Participation::getSubject and the set 1 and 2 attached to lesson C becomes a separate group.

I have currently 'solved' the problem as follows, with a custom UniConstraintCollection:

        return constraintFactory
                .forEach(Participation.class)
                .groupBy(Participation::getStudent, new UniConstraintCollector<Participation, Map<Subject, List<Participation>>, Map<Subject, List<Participation>>>() {
                            @Override
                            public Supplier<Map<Subject, List<Participation>>> supplier() {
                                return HashMap::new;
                            }

                            @Override
                            public BiFunction<Map<Subject, List<Participation>>, Participation, Runnable> accumulator() {
                                return (map, participation) -> {
                                    for(Subject s : participation.getSubjects()) {
                                        if(!map.containsKey(s)) {
                                            map.put(s, new ArrayList<>());
                                        }
                                        map.get(s).add(participation);
                                    }

                                    return () -> {
                                        for(Map.Entry<Subject, List<Participation>> entry: map.entrySet()) {
                                            entry.getValue().remove(participation);
                                        }
                                    };
                                };
                            }

                            @Override
                            public Function<Map<Subject, List<Participation>>, Map<Subject, List<Participation>>> finisher() {
                                return Function.identity();
                            }
                }).penalize(("name", HardSoftScore.ONE_SOFT, (student, participationMap) -> {
                    ...
                });)

This works, in that I receive a map of Subject to Participations and allows me to calculate the penalty I want.

However, this means that I calculate one penalty value for all subject/list combinations 'together'. From a usability perspective, I would like to penalize each subject/list separately. Is there a way to do this? (Maybe by rewriting the UniConstraintCollector to provide multiple lists of Participations, instead of one single Map<Subject, List<Participation>>?)

P.S. Another approach I have tried to achieve the same goal is to work from the perspective of the Subject. So, making Subjects a ProblemFact on the PlanningSolution, and working with something like

        return constraintFactory
                .forEach(Subject.class)
                .join(constraintFactory.forEach(Participation.class),
                        JoinerSupport.getJoinerService().newBiJoiner(List::of, JoinerType.INTERSECTING, Participation::getSubjects)
                )

I assume this is the way I would need to go, using the JoinerType INTERSECTING, but this gives me an "Unsupported Joiner Type" exception in AbstractLeftHandSide.


Solution

  • What if you start from Subject?

    constraintFactory
        .forEach(Subject.class)
        .join(Participation.class,
            // Joiners.containedBy() would do this far more efficient
            filtering((s, p) -> p.getSubjects().contains(s))
        .groupBy((s, p) -> s, toList((s, p) -> p))
    

    This could be an expensive constraint performance wise. Benchmark it.