@@ -702,13 +702,6 @@ You can also provide your own collectors by implementing the
702702`ai.timefold.solver.core.api.score.stream.uni.UniConstraintCollector` interface,
703703or its `Bi...`, `Tri...` and `Quad...` counterparts.
704704
705- NOTE: Custom collectors can opt into incremental mode by having their `accumulator()` method return a
706- `*ConstraintCollectorAccumulator` instance (e.g. `UniConstraintCollectorAccumulator`) instead of a plain lambda.
707- In incremental mode, the accumulator receives insert/update/retract calls individually,
708- which is more efficient than the default full retract-and-reinsert on update.
709- The solver detects incremental support via `instanceof` automatically — no other method needs overriding.
710- `*ConstraintCollectorAccumulator` extends the corresponding function type but throws `UnsupportedOperationException`
711- if called as a function; use it via its `intoGroup()` method instead.
712705
713706
714707[#collectorsCount]
@@ -1194,6 +1187,146 @@ ConstraintCollectors.collectAndThen(
11941187====
11951188
11961189
1190+ [#constraintStreamsCustomCollector]
1191+ ==== Implementing a custom collector
1192+
1193+ When no built-in collector covers the required aggregation,
1194+ implement `UniConstraintCollector<A, ResultContainer_, Result_>` directly
1195+ (or its `Bi...`, `Tri...`, `Quad...` counterparts for higher-arity streams).
1196+ The interface has three methods:
1197+
1198+ [cols="1,3"]
1199+ |===
1200+ | Method | Purpose
1201+
1202+ | `supplier()`
1203+ | Creates a fresh mutable result container for each group.
1204+
1205+ | `accumulator()`
1206+ | Called when a fact enters a group; returns a `UniConstraintCollectorValueHandle`
1207+ whose `add()`, `replaceWith()`, and `remove()` methods maintain the container.
1208+
1209+ | `finisher()`
1210+ | Converts the container into the immutable group result.
1211+ |===
1212+
1213+ The following example shows a simplified version of the built-in `toList()` collector:
1214+
1215+ [tabs]
1216+ ====
1217+ Java::
1218+ +
1219+ [source,java,options="nowrap"]
1220+ ----
1221+ public class SimpleToListCollector<A>
1222+ implements UniConstraintCollector<A, List<A>, List<A>> {
1223+
1224+ @Override
1225+ public Supplier<List<A>> supplier() {
1226+ return ArrayList::new;
1227+ }
1228+
1229+ @Override
1230+ public UniConstraintCollectorAccumulator<List<A>, A> accumulator() {
1231+ return list -> new UniConstraintCollectorValueHandle<>() {
1232+ private A current;
1233+
1234+ @Override
1235+ public void add(A element) {
1236+ current = element;
1237+ list.add(element);
1238+ }
1239+
1240+ @Override
1241+ public void remove() {
1242+ list.remove(current);
1243+ current = null;
1244+ }
1245+ };
1246+ }
1247+
1248+ @Override
1249+ public Function<List<A>, List<A>> finisher() {
1250+ return Collections::unmodifiableList;
1251+ }
1252+ }
1253+ ----
1254+ ====
1255+
1256+ `accumulator()` returns a factory: for each fact that enters a group,
1257+ the solver calls `intoGroup(container)` to obtain a fresh handle,
1258+ then calls `add()` exactly once, zero or more `replaceWith()` calls as the fact changes,
1259+ and `remove()` at most once when the fact leaves the group.
1260+
1261+ `list.remove(current)` removes by value (runs in linear time).
1262+ You might consider storing the insertion index instead and removing by index (runs in constant time),
1263+ but that would be risky —
1264+ any removal can shift subsequent indices at any time,
1265+ making stored positions unreliable.
1266+ Thankfully, there is a better way, using an incremental update.
1267+
1268+ When a planning variable changes, the solver calls `replaceWith()` on the affected handle.
1269+ The default implementation (inherited from `UniConstraintCollectorValueHandle`) does `remove()` followed by `add()`.
1270+ For `ArrayList`, this means scanning to find the element in linear time,
1271+ then shifting all subsequent elements to close the gap, and finally appending the new value at the end.
1272+
1273+ Overriding `replaceWith()` with `list.set()` avoids the shift entirely:
1274+ `set()` replaces the element in-place at its current position —
1275+ no elements are moved, and the element's position in the list is preserved.
1276+
1277+ [tabs]
1278+ ====
1279+ Java::
1280+ +
1281+ [source,java,options="nowrap"]
1282+ ----
1283+ public class IncrementalToListCollector<A>
1284+ implements UniConstraintCollector<A, List<A>, List<A>> {
1285+
1286+ @Override
1287+ public Supplier<List<A>> supplier() {
1288+ return ArrayList::new;
1289+ }
1290+
1291+ @Override
1292+ public UniConstraintCollectorAccumulator<List<A>, A> accumulator() {
1293+ return list -> new UniConstraintCollectorValueHandle<>() {
1294+ private A current;
1295+
1296+ @Override
1297+ public void add(A element) {
1298+ current = element;
1299+ list.add(element);
1300+ }
1301+
1302+ @Override
1303+ public void replaceWith(A element) {
1304+ list.set(list.indexOf(current), element);
1305+ current = element;
1306+ }
1307+
1308+ @Override
1309+ public void remove() {
1310+ list.remove(current);
1311+ current = null;
1312+ }
1313+ };
1314+ }
1315+
1316+ @Override
1317+ public Function<List<A>, List<A>> finisher() {
1318+ return Collections::unmodifiableList;
1319+ }
1320+ }
1321+ ----
1322+ ====
1323+
1324+ NOTE: The above implementation of `replaceWith()` is still not ideal,
1325+ as it requires a linear scan to find the element.
1326+ This will cause constraint scaling issues in larger lists,
1327+ and is the main reason why we do not recommend using `toList()` in performance-sensitive constraints.
1328+
1329+
11971330[#constraintStreamsConditionalPropagation]
11981331=== Conditional propagation
11991332
0 commit comments