kuilt Help

PNCounter

A counter that can go up or down. Two GCounters under the hood — one for increments, one for decrements. The current value is the difference.

Converges to: inc.value - dec.value, where each half is a GCounter that only ever grows.

Worked example — live vote tally over two peers

PNCounter + Quilter is the natural fit for a vote tally: each peer owns its own slot, increments record upvotes, decrements record downvotes. Deltas propagate automatically; both replicas converge to the same net count.

/** * Live vote tally over two peers using [PNCounter] + [Quilter]. * * Each peer owns its own [ReplicaId] slot: increments record upvotes, decrements * record downvotes. [Quilter] broadcasts deltas automatically; both replicas * converge to the same net count regardless of which peer applied which vote. * * Alias for the `upvotes and downvotes from two peers converge to the correct net tally` * test in `VoteTallyTest` — the backtick name can't be an `include-symbol` target. */ @Suppress("unused") internal fun sampleVoteTally() = runTest(StandardTestDispatcher(), timeout = 5.seconds) { val loom = InMemoryLoom() val seamAlice = loom.host(Pattern("vote-tally")) val seamBob = loom.join(InMemoryTag("bob")) val cfg = QuilterConfig(expectVirtualTime = true) val aliceTally = Quilter(seamAlice, PNCounter.ZERO, PNCounter.serializer(), backgroundScope, config = cfg) val bobTally = Quilter(seamBob, PNCounter.ZERO, PNCounter.serializer(), backgroundScope, config = cfg) kotlinx.coroutines.delay(1) // let collectors subscribe under StandardTestDispatcher // Alice casts 3 upvotes for the post. aliceTally.mutate { it.increment(aliceTally.replica, 3L) } // Bob casts 1 upvote and then 1 downvote (changed his mind). bobTally.mutate { it.increment(bobTally.replica, 1L) } bobTally.mutate { it.decrement(bobTally.replica, 1L) } // Alice adds another upvote concurrently. aliceTally.mutate { it.increment(aliceTally.replica, 2L) } kotlinx.coroutines.delay(10) // advance virtual time so all delta broadcasts deliver // Both replicas converge to the same net tally: alice +3+2=5, bob +1−1=0 → total 5. assertEquals(5L, aliceTally.state.value.value) assertEquals(aliceTally.state.value.value, bobTally.state.value.value) }

See the full test at VoteTallyTest.kt.

Merge rule

A PNCounter is two independent GCounters in a product lattice — one for increments (inc), one for decrements (dec). Joining two PNCounters joins each half separately. Idempotent, commutative, associative by the same argument that holds for GCounter.

value = inc.value - dec.value

There is no floor at zero. A replica can decrement without having incremented — value can go negative.

Code examples

Increment:

@Test fun incrementRaisesValue() { val pn = PNCounter.ZERO val next = pn.piece(pn.increment(a, 3L)) assertEquals(3L, next.value) }

Decrement:

@Test fun decrementLowersValue() { val pn = PNCounter.ZERO.piece(PNCounter.ZERO.increment(a, 5L)) val next = pn.piece(pn.decrement(a, 2L)) assertEquals(3L, next.value) }

Concurrent increments from different replicas merge:

@Test fun concurrentIncAndDecFromDifferentReplicasMerge() { val zero = PNCounter.ZERO val aInc = zero.piece(zero.increment(a, 10L)) val bDec = zero.piece(zero.decrement(b, 3L)) // Both sides merge; value = 10 - 3 = 7 val merged = aInc.piece(bDec) assertEquals(7L, merged.value) }

Value can go negative:

@Test fun valueCanGoNegative() { // The dec GCounter is independent — value = inc - dec, no floor at zero. val pn = PNCounter.ZERO.piece(PNCounter.ZERO.decrement(a, 5L)) assertEquals(-5L, pn.value) }

When to use

Need

Use

Concurrent add/remove of an integer, any sign

PNCounter

Shared budget that must never go negative

BoundedCounter

Only ever counts up

GCounter

Last modified: 22 June 2026