The filter and fn overloads dropped ctx from the front. The path traversal API hadn’t. Every call still looked like (pathCtx, lib) -> lib.rooms() — the unused first argument sitting there as a reminder that the work wasn’t finished.

Three Forms, Not Six

The first question was how many overloads path() needs. Predicates already have two forms: no-ctx and ctx-at-end. Traversal functions are the same: navigate from the current element only, or navigate with access to the accumulated path context.

That’s a 2×2 grid, but one cell is redundant. If the traversal doesn’t need ctx, the predicate can either way. But if the traversal does use ctx, you’ve already declared you’re in path-context territory — the predicate that follows should be consistent. I made rule 3 explicit: traversal with ctx implies predicate with ctx. No mixed overloads.

Four overloads total, not eight. And the rule is enforced structurally — the missing combinations simply don’t exist.

// no-ctx traversal, no predicate — method references work
.path(Library::rooms)

// no-ctx traversal, element-only predicate
.path(lib -> lib.rooms(), book -> book.published())

// ctx-at-end traversal, no predicate
.path((lib, pathCtx) -> lib.rooms())

// ctx-at-end both — rule 3: if traversal has ctx, predicate must too
.path((shelf, pathCtx) -> shelf.books(),
      (book, pathCtx) -> pathCtx.getTuple().getB().name().equals("Physics"))

The Erasure Problem, Again

Vol2 has Function1<A, R> already. When we applied the same redesign there, I tried using Predicate2<B, PathContext<T>> for the ctx-at-end predicate. The compiler disagreed: Predicate2<B, PathContext<T>> and Predicate2<PathContext<T>, B> both erase to Predicate2. Same class, different type args — same erasure.

The fix is the same one we used for filter and fn: a distinct interface class. CtxLastPredicate1<A, DS> goes alongside Predicate1<A> in vol2’s function package. Different class name, different erasure, no conflict.

The keepTemplate=true Trap

Vol2’s Path3 uses keepTemplate=true — the template class itself is preserved in the generated output alongside Path4, Path5, Path6. The sandbox uses keepTemplate=false, so the template class is never emitted.

We added @PermuteBody to the new overloads and used return null; as the placeholder body — standard practice in the sandbox where the template class is discarded. In vol2, Path3.path(Function1, Predicate1) returned null at runtime. @PermuteBody had replaced the generated classes but left the template class untouched.

The fix is mechanical: write actual delegation bodies in the template class alongside @PermuteBody. The template uses the real body; the generated classes use the annotation. One gotcha with no compile-time warning.

Array and Single-Object Traversal

Function1<A, B[]> and Function1<A, Iterable<B>> both erase to Function1. They need different method names: pathArray() and pathSingle().

Both wrap at the DSL boundary before calling the private core — no runtime changes. Array: Arrays.asList(fn.apply(a)). Single: a null-safe block lambda that produces an empty list when the traversal returns null, pruning the path branch cleanly.

The wrapping approach means Library::roomsArray and lib -> lib.headRoom() both work as method references, and the ListPathNode traversal loop doesn’t need to know the difference.


<
Previous Post
What the Tests Found
>
Next Post
Optional by design, and a PostgreSQL test that told the truth