PNCounter
A positive/negative counter: two GCounter halves composed in a product lattice. One half tracks all increments, the other all decrements.
Intuition: each half is an independent GCounter whose piece is elementwise max. Joining the product of two such lattices is just joining each component separately. The observable value is inc − dec, which can be negative if decrements outpace increments.
Delta-state: increment and decrement do not mutate — each returns a Patch (a tiny PNCounter carrying only the changed slot in one half) that any replica absorbs with piece. This mirrors GCounter.inc.
Replica ownership: each replica increments/decrements its own slot. Two replicas must never mutate the same slot concurrently — that is the caller's responsibility, exactly as with GCounter.
Signed vs unsigned: the counters are Long-based throughout. A replica decrementing more than it (or any peer) has incremented is legal — value goes negative. This matches standard PNCounter semantics. If you need a floor at zero, use BoundedCounter instead.
No wraparound risk: both halves count upward monotonically; value is a subtraction of two non-negative Long sums. Overflow is theoretically possible at Long.MAX_VALUE increments per replica, which is not a practical concern.
Samples
val a = ReplicaId("A")
val b = ReplicaId("B")
var counter = PNCounter.ZERO
counter = counter.piece(counter.increment(a, 10))
counter = counter.piece(counter.decrement(b, 3))
check(counter.value == 7L)