diff --git a/CHANGELOG.md b/CHANGELOG.md index d3516f0..e35f3c8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Java 2.10.4 — 2026-07-02 + +**Fix: ν-net join match dropped by `bindActions` (NU-030)** + +Follow-up to 2.10.3, which fixed the composition drop sites (`mergeTransitions`, `rebuild_with_name`) but missed a third: `PetriNet.bindActions` rebuilds every transition (`rebuildWithAction`) to attach its action and never carried `matchSpec` forward. A net composed with an intact ν-net join therefore lost its correlation the moment actions were bound, reverting a correlated join-by-id to a plain FIFO AND join at runtime — pairing tokens by arrival order across overlapping fork/join generations (a stale cross-group result). + +- **Java:** `rebuildWithAction` now carries the transition's `matchSpec` as-is (bindActions does not rename places, so no remap is applied — unlike `rebuildWithName`). +- Regression tests added: `bindActions_preservesMatchSpec` (structural) and `bindActions_matchedJoin_correlatesByName_notFifo` (behavioral — a bound matched join pairs by name, not FIFO). + ## Java 2.10.3 / TypeScript 2.10.3 / Rust 3.4.3 / Python 2.13.1 — 2026-07-01 **Fix: ν-net join match dropped during modular composition (NU-030 / NU-060)** diff --git a/java/pom.xml b/java/pom.xml index bd978f2..5f16257 100644 --- a/java/pom.xml +++ b/java/pom.xml @@ -6,7 +6,7 @@ org.libpetri libpetri - 2.10.3 + 2.10.4 jar libpetri diff --git a/java/src/main/java/org/libpetri/core/PetriNet.java b/java/src/main/java/org/libpetri/core/PetriNet.java index 5cd9d79..e300ff1 100644 --- a/java/src/main/java/org/libpetri/core/PetriNet.java +++ b/java/src/main/java/org/libpetri/core/PetriNet.java @@ -171,6 +171,15 @@ private static Transition rebuildWithAction(Transition t, TransitionAction actio t.reads().forEach(builder::readArc); t.resets().forEach(builder::resetArc); + // NU-030: carry the ν-net join correlation forward. bindActions only + // attaches an action — it does not rename places — so the matchSpec's + // input-place references are still valid and it is carried as-is (no + // remap, unlike rebuildWithName). Dropping it here would silently revert + // a correlated join-by-id to a plain FIFO AND join at runtime. + if (t.matchSpec() != null) { + builder.match(t.matchSpec()); + } + return builder.build(); } diff --git a/java/src/test/java/org/libpetri/core/PetriNetTest.java b/java/src/test/java/org/libpetri/core/PetriNetTest.java index 61e4afd..f6ff33d 100644 --- a/java/src/test/java/org/libpetri/core/PetriNetTest.java +++ b/java/src/test/java/org/libpetri/core/PetriNetTest.java @@ -1,7 +1,11 @@ package org.libpetri.core; import org.junit.jupiter.api.Test; +import org.libpetri.runtime.BitmapNetExecutor; +import java.time.Instant; +import java.util.ArrayList; +import java.util.List; import java.util.Map; import java.util.concurrent.CompletableFuture; @@ -227,4 +231,85 @@ void bindActions_preservesInputSpecs() { assertNotNull(boundTransition.outputSpec(), "bindActions should preserve outputSpec"); } + + @Test + void bindActions_preservesMatchSpec() { + // NU-030 regression: bindActions rebuilds each transition to attach its + // action (rebuildWithAction). It must carry a ν-net join's matchSpec + // forward — dropping it silently reverts a correlated join-by-id to a + // plain FIFO AND join at runtime. + Place a = Place.of("branchA", String.class); + Place b = Place.of("branchB", String.class); + Place out = Place.of("merged", String.class); + + var join = Transition.builder("join") + .inputs(Arc.In.one(a), Arc.In.one(b)) + .match(MatchSpec.builder() + .key(a, (String s) -> NameId.of(s)) + .key(b, (String s) -> NameId.of(s)) + .build()) + .outputs(Arc.Out.place(out)) + .build(); + + var net = PetriNet.builder("matchBind").transitions(join).build(); + assertNotNull(net.transitions().iterator().next().matchSpec(), + "precondition: structure carries the matchSpec"); + + var bound = net.bindActions(Map.of("join", ctx -> { + ctx.output(out, ctx.input(a) + "+" + ctx.input(b)); + return CompletableFuture.completedFuture(null); + })); + + var boundJoin = bound.transitions().iterator().next(); + assertNotNull(boundJoin.matchSpec(), + "bindActions must preserve the ν-net matchSpec (regression: rebuildWithAction dropped it)"); + assertTrue(boundJoin.matchSpec().correlates(a), "match still correlates branchA"); + assertTrue(boundJoin.matchSpec().correlates(b), "match still correlates branchB"); + } + + @Test + void bindActions_matchedJoin_correlatesByName_notFifo() { + // Behavioral regression for the reported production symptom (marvin text + // guard join): a matched join whose action is attached via bindActions + // must still pair tokens by name — not by FIFO arrival order (which + // would serve a stale cross-group result). + Place a = Place.of("branchA", String.class); + Place b = Place.of("branchB", String.class); + Place out = Place.of("merged", String.class); + + var join = Transition.builder("join") + .inputs(Arc.In.one(a), Arc.In.one(b)) + .match(MatchSpec.builder() + .key(a, (String s) -> NameId.of(s)) + .key(b, (String s) -> NameId.of(s)) + .build()) + .outputs(Arc.Out.place(out)) + .build(); + + var net = PetriNet.builder("matchBindRun") + .transitions(join) + .build() + .bindActions(Map.of("join", ctx -> { + ctx.output(out, ctx.input(a) + "+" + ctx.input(b)); + return CompletableFuture.completedFuture(null); + })); + + // Interleaved timestamps: FIFO would cross-pair (X+Y / Y+X); a live + // match yields the correlated X+X / Y+Y. + var initial = Map., List>>of( + a, List.of(new Token<>("X", Instant.ofEpochMilli(0)), + new Token<>("Y", Instant.ofEpochMilli(1))), + b, List.of(new Token<>("Y", Instant.ofEpochMilli(0)), + new Token<>("X", Instant.ofEpochMilli(1))) + ); + + try (var executor = BitmapNetExecutor.create(net, initial)) { + var marking = executor.run(); + var vals = new ArrayList(); + for (var tk : marking.peekTokens(out)) vals.add(tk.value()); + java.util.Collections.sort(vals); + assertEquals(List.of("X+X", "Y+Y"), vals, + "bound matched join must correlate by name (regression: bindActions dropped match FIFO-pairs to X+Y/Y+X)"); + } + } }