cld-toys › Toys › vector-clock-crdt

Commentary: vector-clock-crdt

Two replicated sets, both convergent. Gossip 60× harder and one goes 1.960 wrong elements to 0.130, while the other reads 1.280 on every single row — the same set, per trace, on 200 of 200 traces. A study guide for crdt.py.

vector-clock-crdt/ on GitHub·the source with line numbers, for reading rather than downloading
How to read this This is the only documentation the toy has — read it with crdt.py open beside you. crdt.py is the toy itself (233 lines, raw wc -l: two replicated-set classes and eight module-level functions); demo.py runs the five scenarios of §6; test_crdt.py locks it down with 19 tests. Every transcript below was captured from a real run on macOS 26.5.2 (Darwin 25.5.0, arm64, Apple M1 Max), Python 3.15.0a8 (main, Apr 14 2026, 14:20:41) [Clang 22.1.3], stdlib only.
cd vector-clock-crdt
python3 demo.py       # the aha (§6), about 7 seconds
python3 test_crdt.py  # pins every number this page claims, about 30 seconds
Contents
  1. Orientation
  2. The problem this mechanism exists to solve
  3. Background you need
  4. The mental model
  5. Reading the source
  6. The demo, and what it proves
  7. Design decisions and roads not taken
  8. What's simplified vs. the real thing
  9. Check yourself
  10. Further reading

1. Orientation

This toy is a replicated set, built twice. Both copies accept writes on any replica with no coordination, both merge by absorbing another replica's whole state, and both are guaranteed to end up agreeing. They differ in one place: what they consult to decide a conflict.

The OR-Set stamps every operation with a dot — a (replica_id, counter) pair — and keeps a per-replica vector clock of the dots it has observed. At merge time it asks a question about the network: did you ever see this assertion? The LWW store keeps no dots and no vector clock. It asks a question about a wall clock: is your timestamp bigger than mine?

The backlog entry for this toy promised the standard CRDT demonstration — two replicas write concurrently, merge, and converge without a coordinator. That is true, and it is worthless as a lesson, because it is the definition of a CRDT rather than a consequence of one. It confirmed on every run of every experiment on this page, including the runs where the merge rule was deliberately broken. A claim that survives breaking the mechanism is not teaching the mechanism.

So the toy measures something else: distance from the answer one machine would hold if it applied the same operations in the same order. Under that metric, with three replicas gossiping at random and every replica's clock skewed by a random amount:

Syncing harder cannot make a last-write-wins store more correct. Not by one element. The vector clock is what makes communication worth anything.

By the end you should be able to:


2. The problem this mechanism exists to solve

You have the same logical set on three machines. Any of them may accept a write while partitioned from the others. Nobody may block waiting for a quorum, because the whole point of the design was to keep taking writes when the network is down. Sooner or later two replicas that have diverged must be put back together, and something has to decide what happens when replica A says "x is in the set" and replica B says "x was removed."

The obvious answer is to ask which happened later. Attach a timestamp to every write, and on merge keep the one with the bigger number. This is what Cassandra does, what Riak does when allow_mult is false, and what almost every "eventually consistent" store reaches for first. It is genuinely excellent engineering: it is O(1) metadata per key, it needs no history, and it converges trivially, because max is commutative, associative and idempotent no matter what order the merges happen in.

It has one property that is easy to state and hard to feel: it decides conflicts using a number that has nothing to do with the conflict. Two clocks that disagree by more than the gap between the two operations will order them backwards, and no amount of subsequent communication changes the answer, because the answer was fully determined the moment the timestamps were written.

The alternative is to stop asking when and start asking whether — whether the replica that issued the delete had already observed the add. That question is answerable exactly, with no clock at all, if every operation carries a unique id and every replica remembers which ids it has seen. Two operations are concurrent precisely when neither replica had seen the other's id. When they are concurrent there is no correct answer, only a policy — and the whole value of the machinery is that it tells you which conflicts are real, so you only apply the policy where you have to.

The competing goals, and the reason more than one design ships in production:


3. Background you need

None of this is deep, but everything below leans on it.

ConceptWhere it's used hereOne source
Happened-before Replica.covers() — the only question merge ever asks, and the reason no clock appears in the OR-Set's answer Lamport, Time, Clocks, and the Ordering of Events
Vector clock / version vector Replica.cc, a dict {replica_id: highest counter seen}; merged by pointwise max at the end of merge Riak: Causal Context
Dot — one operation's unique id _assert mints (self.rid, self.seq); it is what a vector clock is a compressed summary of Bieniusa et al., An optimized conflict-free replicated set
OR-Set (observed-remove set) The whole of Replica: a remove supersedes the adds it has observed, and nothing else Bieniusa et al.
Strong eventual consistency The converged column that reads True in every table on this page — including the broken variants Preguiça, Baquero & Shapiro, Conflict-free Replicated Data Types
Last-write-wins register PureLWW — 27 lines, no dots, no causal context Jepsen: Cassandra
Clock skew run(..., skew={0: 9}), an explicit integer offset added to one replica's stamps; never a wall clock Jepsen: Cassandra

The two that carry the result are the first and the last. Happened-before is a relation between events, established by message passing, and it is decidable exactly — which is why the OR-Set's error responds to gossip. Clock skew is a property of hardware, and no message can fix it — which is why the LWW store's error does not respond to anything. Everything on this page is that one contrast, measured.


4. The mental model

Before any code. Three pictures: what a replica holds, what merge decides, and what the two structures are actually asking.

A replica is a vector clock plus a bag of surviving assertions, each tagged with the dot that minted it:

Replica r1, after `r1 add e3`, then r2's `add e3` arrives in a merge cc = { 1: 1, <- "I have seen r1's operations up to counter 1" 2: 1 } <- "...and r2's up to counter 1" el = { "e3": { (1,1): ("add", 0), <- r1's own assertion (2,1): ("add", 1) } } <- r2's, learned by merging value() = {e3}

cc is a summary: {2: 7} means "I have seen every one of r2's operations from 1 through 7", which is only meaningful because a replica issues its dots in order and never skips. That compression is the entire reason a vector clock is small.

Now the merge. For each dot the local replica holds, there are exactly three cases, and the middle one is the mechanism:

For each dot d I hold for element e: d is also in their el[e] -> KEEP. We both still assert it. d is absent AND they cover d -> DROP. They saw it and superseded it. Their deletion is news to me. d is absent AND they don't -> KEEP. They have never heard of it. cover d Silence is not a deletion. ...and symmetrically for each dot they hold. What survives is exactly the set of assertions that are pairwise concurrent.

The second and third lines are the same observation from opposite sides: absence only means something if you can prove the other side once had it. That proof is what the vector clock is for. Take it away and every absence looks like ignorance, so nothing is ever deleted — measured in §5.4.

Finally, the two structures asking about the same pair of operations. This is the toy in one picture:

Replica 0's clock runs 9 ticks fast. Two operations on e4: t=3 r0 add e4 t=5 r1 rm e4 dot (0,1) dot (1,3) stamp 3 + 9 = 12 stamp 5 (r0's 1st operation) (r1's 3rd: it also wrote e3 twice) The OR-Set asks: had r1 already seen dot (0,1) when it wrote? sync after every op -> yes -> the rm supersedes the add -> e4 ABSENT never sync -> no -> concurrent, add-wins -> e4 PRESENT ^ the answer moves with the network The LWW store asks: is 12 > 5? always -> yes -> e4 PRESENT ^ the question does not mention the network at all

Three things fall out of that picture, and they are the whole toy:


5. Reading the source

crdt.py is 233 lines (raw wc -l). Read it in this order: the policy function, then how an operation is recorded, then the two lines that decide everything, then the structure being argued with.

5.1 present — three policies, one line each

crdt.py · lines 29–41
def present(assertions, policy):
    """Is the element in the set? `assertions` is {dot: (label, ts)}."""
    if not assertions:
        return False
    if policy == "add-wins":
        return any(lbl == "add" for lbl, _ in assertions.values())
    if policy == "rm-wins":
        return all(lbl == "add" for lbl, _ in assertions.values())
    if policy == "lww":
        # Highest timestamp wins, ties broken by dot so it stays deterministic.
        _, (lbl, _) = max(assertions.items(), key=lambda kv: (kv[1][1], kv[0]))
        return lbl == "add"
    raise ValueError(policy)

This function only ever sees assertions that merge has already decided are concurrent. That is the division of labour worth taking away from this toy: the vector clock finds the genuine conflicts, and the policy is a tiebreak applied only to those. any versus all is the entire difference between add-wins and remove-wins semantics.

The policy is a business decision dressed as a data structure. It is also completely invisible until there is concurrency to apply it to — at sync_every=1, add-wins and rm-wins produce identical values on 200 of 200 traces, and at sync_every=40 they differ on 200 of 200 (§6.5).

Note the third policy exists to make a point rather than to be used: lww puts a timestamp comparison inside the CRDT, which is what a real LWW-Element-Set does. It is not the same thing as PureLWW, because it only consults the clock for assertions that are actually concurrent. At sync_every=1 it is exactly right on every trace — there is never more than one surviving assertion, so the clock is never read.

5.2 _assert — minting a dot, and superseding what you can see

crdt.py · lines 47–65
    def __init__(self, rid, policy="add-wins", supersede=True, causal_merge=True):
        self.rid = rid
        self.policy = policy
        self.supersede = supersede        # counterfactual knob, see commentary 5.5
        self.causal_merge = causal_merge  # counterfactual knob, see commentary 5.4
        self.seq = 0
        self.cc = {}   # rid -> highest counter observed from that replica
        self.el = {}   # elem -> {dot: (label, ts)}

    # ---- local operations -------------------------------------------------
    def _assert(self, elem, label, ts):
        """Mint a fresh dot and supersede everything this replica can see."""
        self.seq += 1
        dot = (self.rid, self.seq)
        self.cc[self.rid] = self.seq
        if self.supersede:
            self.el[elem] = {dot: (label, ts)}
        else:
            self.el.setdefault(elem, {})[dot] = (label, ts)

self.el[elem] = {dot: ...} — a plain assignment, throwing away every assertion this replica currently holds for the element. That looks careless and is the load-bearing half of the OR-Set: an operation supersedes exactly the assertions the issuing replica had already observed, because those are, by definition, causally before it. The ones it has not seen are untouched because they are not in self.el to touch.

add and remove differ only in the label they pass. There is no tombstone list, no separate remove-set, and no distinction in the data structure between an add and a remove — which is why a third policy costs six lines.

Two things the local write does not do, both deliberate:

5.3 covers — the vector clock's entire job

crdt.py · lines 74–76
    def covers(self, dot):
        """Have I ever observed this dot? The vector clock's whole job."""
        return self.cc.get(dot[0], 0) >= dot[1]

Three lines, and every result on this page routes through them. .get(rid, 0) means "a replica I have never heard from covers nothing", which is the correct reading: no news is not a deletion.

>= and not >. The counter in a dot is the sequence number of the operation that minted it, so seeing "r0's operations up to 5" means dot (0,5) has been observed. I ran the off-by-one rather than reasoning about it:

mean elements wrong / traces that did NOT converge, 200 seeds variant sync_every=1 sync_every=8 sync_every=120 baseline add-wins 0.000 / 0 0.650 / 0 2.185 / 0 covers(): `>=` -> `>` 0.495 / 0 0.995 / 0 2.185 / 0

0.000 → 0.495 at sync_every=1. Every replica still converges — the mistake is uniform, so everyone makes it identically. What it costs is that the most recent operation each replica knows about is treated as unseen forever, so a delete never supersedes the newest add. And look at the last column: at sync_every=120 the bug is completely inert, 2.185 either way. A replica that has observed nothing covers nothing, so an off-by-one in "how much have I observed" changes no answer at all. The same character is worth half an element at one end of the axis and exactly nothing at the other.

5.4 merge — the load-bearing line

crdt.py · lines 78–102
    def merge(self, other):
        """Absorb `other`. Keep an assertion the other side lacks only if the
        other side never saw it; if it saw it and dropped it, it superseded
        it, and the drop is news."""
        merged = {}
        for elem in set(self.el) | set(other.el):
            mine = self.el.get(elem, {})
            theirs = other.el.get(elem, {})
            if not self.causal_merge:
                keep = dict(mine)
                keep.update(theirs)
            else:
                keep = {}
                for dot, v in mine.items():
                    if dot in theirs or not other.covers(dot):
                        keep[dot] = v
                for dot, v in theirs.items():
                    if dot in mine or not self.covers(dot):
                        keep[dot] = v
            if keep:
                merged[elem] = keep
        self.el = merged
        for rid, seq in other.cc.items():
            if seq > self.cc.get(rid, 0):
                self.cc[rid] = seq

if dot in theirs or not other.covers(dot) is the most load-bearing line in the file. Delete the coverage test — take the causal_merge=False branch, which makes merge a plain union of assertions — and:

=== mean elements wrong, add-wins === variant sync_every=1 sync_every=8 sync_every=120 converged baseline (add-wins) 0.000 0.650 2.185 True causal_merge=False 2.710 2.685 2.185 True supersede=False 2.710 2.710 2.710 True both off 2.710 2.710 2.710 True

At sync_every=1, 0.000 → 2.710. The structure still converges — the converged column is True on every row — and it has become grow-only. It is not approximately grow-only; at sync_every=1 the merged value is exactly the set of every element the trace ever touched, on 200 of 200 traces:

=== is a plain-union merge still a CRDT? does it ever delete? === causal_merge=False, sync_every=1 traces whose value is NOT 'every element ever touched': 0/200 causal_merge=False, sync_every=8 traces whose value is NOT 'every element ever touched': 4/200 causal_merge=False, sync_every=120 traces whose value is NOT 'every element ever touched': 85/200

That is the sharpest single fact in the toy. Take out the vector-clock test and you still have a CRDT by every formal definition — commutative, associative, idempotent, strongly eventually consistent — and it cannot delete anything, ever. Every property the literature guarantees is intact and the data type is useless. Convergence is not the property doing the work.

Read the last column again: at sync_every=120 the line is inert, 2.185 with it and 2.185 without. If the replicas never exchange anything mid-trace, no replica covers any other replica's dots, so the coverage test never fires. The line is worth everything exactly where the toy's headline lives and nothing at the other end of the same axis — which is a useful thing to know before writing a paragraph about how essential it is.

The dot in theirs clause looks like a cheap short-circuit and is not optional. Drop it, so the rule is only not other.covers(dot):

merge(): drop `dot in theirs` 3.290 / 0 3.290 / 0 3.290 / 0

3.290 at every schedule — and that number turns out not to be a degradation at all:

--- does dropping `dot in theirs` empty the set outright? --- traces whose merged value is the EMPTY set: 200/200 mean size of the single-node answer: 3.290

The set is empty on 200 of 200 traces, and 3.290 is simply the mean size of the correct answer. Without that clause, any assertion both replicas still hold is dropped by both of them at the first merge, because each side covers the other's dot. The structure deletes everything and converges perfectly on nothing.

The final loop is the vector clock merge: pointwise max. It runs after the assertion loop, and it has to — the loop above calls self.covers(dot), and if self.cc had already absorbed other.cc then self would claim to cover dots it learned about in this very merge, and would drop them.

5.5 supersede — what bounds the state

The second knob in __init__ turns off the local supersede, so an operation appends an assertion instead of replacing the ones it observed. From the table above: 2.710 at every schedule, including sync_every=1. Unlike the coverage test, this one is never inert; without it a replica cannot represent "I superseded that" at all, so its own history piles up and add-wins sees an add from ten operations ago forever.

It is also what bounds the metadata, measured with a real json serializer rather than a modelled byte count:

=== state size in bytes, real json serializer, replica 0 after the trace === structure sync_every=1 sync_every=8 sync_every=120 add-wins 213.1 263.9 453.9 pure-lww 131.1 131.1 131.1 add-wins, supersede=False 2419.9 2419.9 2419.9

213.1 → 2419.9 bytes, 11.4×, for the same 120 operations. And note the honest cost of the whole design in the second row: the LWW store is 131.1 bytes and does not move, because its state is one triple per element and knows nothing about history. The OR-Set pays 1.63× at sync_every=1 and 3.46× when the replicas stay apart — the metadata grows with how concurrent your workload actually was, which is exactly the right shape for it to grow in, and is still a bill.

5.6 PureLWW — the structure being argued with

crdt.py · lines 109–135
class PureLWW:
    """Last write wins on the timestamp alone: no dots, no vector clock."""

    def __init__(self, rid, policy="pure-lww", **_kw):
        self.rid = rid
        self.el = {}   # elem -> (ts, rid, label)

    def _assert(self, elem, label, ts):
        cand = (ts, self.rid, label)
        cur = self.el.get(elem)
        if cur is None or cand[:2] > cur[:2]:
            self.el[elem] = cand

    def add(self, elem, ts):
        self._assert(elem, "add", ts)

    def remove(self, elem, ts):
        self._assert(elem, "rm", ts)

    def merge(self, other):
        for elem, v in other.el.items():
            cur = self.el.get(elem)
            if cur is None or v[:2] > cur[:2]:
                self.el[elem] = v

    def value(self):
        return frozenset(e for e, v in self.el.items() if v[2] == "add")

27 lines against the OR-Set's 63, and it is not a strawman — it is what ships. merge and _assert are the same operation, which is the tell: a local write and a remote merge are indistinguishable to this structure. It cannot tell the difference between "I did this" and "someone told me about this", which is precisely the information the OR-Set spends its metadata to keep.

Note cand[:2] > cur[:2] — comparing (ts, rid), not ts alone. The replica-id tiebreak looks like fussiness and is load-bearing for convergence itself. With timestamps that never collide it is inert, but skew makes them collide, and then:

--- pure-LWW tiebreak, with clocks that actually collide --- (replica 0 skewed by k puts its op at tick t on the same stamp as another replica's op at tick t+k) skew baseline wrong diverged ts-only wrong diverged 0 0.000 0 0.000 0 1 0.000 0 0.135 27 3 0.200 0 0.310 14 7 0.585 0 0.640 2

27 of 200 traces stop converging at skew 1 without the tiebreak. This is the one result on the page where a replica genuinely disagrees with another replica, and it took removing four characters to produce it. It is also a neat inversion of the toy's headline: the tiebreak buys the property everyone talks about (convergence) and buys nothing at all toward the property the toy measures (0.000 → 0.000 at skew 0, where it never fires).

Also worth seeing: the skew-0 row. Given a perfect clock, PureLWW is exactly right — 0.000 wrong — and the OR-Set is not. §6.6 returns to this.

5.7 run_gossip — two RNGs, and why

crdt.py · lines 198–216
def run_gossip(seed, n_replicas, policy, gossip_prob, skews, n_ops=120,
               n_elems=6, p_add=0.55, rounds_at_end=60, **kw):
    """Ops and random pairwise gossip interleaved -- no sync barrier, so no
    replica is ever known to be up to date. Returns (value, converged, truth).

    Two RNGs on purpose: `orng` draws the operations and `grng` draws the
    gossip pairs, so raising `gossip_prob` cannot perturb the trace. Sharing
    one RNG here produced a fabricated result; see commentary 7.5.
    """
    orng = random.Random(seed)
    grng = random.Random(seed ^ 0x5EED)
    reps = _replicas(policy, n_replicas, **kw)
    truth = set()
    for i in range(n_ops):
        rid = orng.randrange(n_replicas)
        elem = "e%d" % orng.randrange(n_elems)
        label = "add" if orng.random() < p_add else "rm"
        op = reps[rid].add if label == "add" else reps[rid].remove
        op(elem, i + skews.get(rid, 0))

The headline needs to vary the gossip rate while holding the operations fixed, and that requires two independent streams. §7.5 is the story of what happened when they were one.

The other decision here is that gossip is one-directional: reps[a].merge(reps[b]) teaches a about b and leaves b unchanged. A replica is therefore never known to be up to date with anything, which is the honest model of anti-entropy and much less tidy than a sync barrier. The headline survives it.


6. The demo, and what it proves

python3 demo.py runs five scenarios. Together they make one argument.

6.1 One trace, three sync schedules

Sixteen operations, three replicas, five elements, replica 0's clock nine ticks fast. The same operations are replayed at three sync schedules.

=== 1. one trace: 16 ops, 3 replicas, 5 elements, replica 0's clock +9 ticks === t=0 r1 add e3 t=1 r2 add e3 t=2 r1 add e3 t=3 r0 add e4 t=4 r0 rm e0 t=5 r1 rm e4 t=6 r2 add e1 t=7 r2 rm e0 t=8 r2 add e2 t=9 r0 add e2 t=10 r2 rm e1 t=11 r1 rm e3 t=12 r1 rm e0 t=13 r2 add e0 t=14 r1 rm e0 t=15 r1 rm e1 what one machine applying these ops in this order would hold: {e2} sync schedule OR-Set (vector clock) LWW store (timestamp) after every op {e2} wrong=0 {e2, e4} wrong=1 every 4 ops {e0, e2} wrong=1 {e2, e4} wrong=1 once, at the end {e0, e2, e3, e4} wrong=3 {e2, e4} wrong=1 every replica agreed with every other in all 6 runs: True

The OR-Set column moves with the network: perfect when the replicas talk after every operation, three elements wrong when they never talk until the end. The LWW column is {e2, e4} three times. Six runs, all three replicas agreeing in every one.

This trace is not cherry-picked for the shape, only for being short. Searching the first 2000 seeds at these parameters, 774 of 2000 produce the same qualitative pattern — LWW identical at all three schedules and wrong, OR-Set exact at sync_every=1 and wrong at 16 — and seed 0 is simply the first.

6.2 Deriving both wrong answers

Neither number is a mystery. demo.py derives them.

e4: t=3 r0 add e4 stamped 12 t=5 r1 rm e4 stamped 5 12 > 5, so the delete loses -- even syncing after every single op. skew LWW value, sync every op e4 present? 0 {e2} False 1 {e2} False 2 {e2} False 3 {e2, e4} True 4 {e2, e4} True 5 {e2, e4} True

r0 adds e4 at tick 3, and its clock is 9 fast, so the write is stamped 12. r1 removes e4 at tick 5 and stamps it 5. The delete happened later in every meaningful sense — later in real time, later in the trace, and issued by a replica that had already been told about the add, because this row is the sync-after-every-operation row. It loses anyway, 12 > 5.

The threshold is exactly where arithmetic says: the add is at t=3 and the delete at t=5, so the add needs +3 ticks to overtake. Skews 0, 1 and 2 leave e4 correctly absent; skew 3 flips it. One tick of clock error is worth exactly one operation of ordering.

The OR-Set's error has an entirely different shape:

e0: t=4 r0 rm e0 t=7 r2 rm e0 t=12 r1 rm e0 t=13 r2 add e0 t=14 r1 rm e0 sync schedule OR-Set value e0 present? sync_every=1 {e2} False sync_every=2 {e2} False sync_every=4 {e0, e2} True sync_every=8 {e0, e2, e4} True sync_every=16 {e0, e2, e3, e4} True

The last two operations on e0 are r2 add at t=13 and r1 rm at t=14 — one tick apart, on different replicas. At sync_every=1 and sync_every=2, r1 has already merged r2's add when it issues the remove, so the remove's dot supersedes it and e0 is correctly absent. At sync_every=4 the two land in the same window, neither replica has seen the other, they are genuinely concurrent, and add-wins keeps e0. No clock is involved in that flip at all — the same operations, the same timestamps, a different message schedule, a different answer.

6.3 The headline

Now the same contrast at scale, on a much less tidy network: three replicas, six elements, 120 operations, 200 seeds, random pairwise gossip rather than a sync barrier, and a random per-replica clock skew drawn from {0, 3, 7, 25, 100}.

=== 3. 200 seeds, 3 replicas, random pairwise gossip, random per-replica skew === gossip OR-Set wrong LWW wrong LWW exact OR-Set false-absent converged 0.00 2.185 1.280 56/200 0.000 True 0.05 1.960 1.280 56/200 0.000 True 0.20 1.350 1.280 56/200 0.000 True 0.50 0.880 1.280 56/200 0.000 True 1.00 0.505 1.280 56/200 0.000 True 3.00 0.130 1.280 56/200 0.000 True

Read the two middle columns. Going from 0.05 exchanges per operation to 3.00 is 60× more gossip, and it takes the OR-Set from 1.960 wrong elements to 0.130 — a 15.1× improvement. From no mid-trace gossip at all to 3.00 it is 2.185 → 0.130, 16.8×. The LWW store reads 1.280 on every row, and the count of traces it gets exactly right is the same 56 of 200 on every row.

converged is True on every row of that table, for both structures. Sixty times the network traffic, and the last-write-wins store is not one element better. It is not a matter of the mean, either — checked per trace:

pure-LWW traces whose final value changed across the 6 gossip rates: 0/200 add-wins traces whose final value changed across the 6 gossip rates: 185/200 traces pure-LWW got exactly right: 56 at gossip 0.00 is it the SAME set of traces at every rate? True

Not "the same number of traces" — the same traces, seed for seed. And where the OR-Set's exact-answer count goes:

add-wins traces exactly right, by gossip rate: gossip 0.00 14/200 gossip 0.05 22/200 gossip 0.20 42/200 gossip 0.50 82/200 gossip 1.00 118/200 gossip 3.00 179/200

14/200 → 179/200 against 56/200 → 56/200. That is the toy.

It is not an artefact of three replicas. At five (c1_headline.py):

=== 5 replicas, random pairwise gossip, RANDOM per-replica skew, 200 seeds === gossip add-wins wrong pure-lww wrong pure-lww exact all converged 0.00 2.660 1.855 26/200 True 0.05 2.535 1.855 26/200 True 0.20 2.210 1.855 26/200 True 0.50 1.745 1.855 26/200 True 1.00 1.220 1.855 26/200 True 3.00 0.520 1.855 26/200 True

Same shape: one column moves 5.1×, the other is flat to three decimal places and holds the same 26 traces exactly right throughout.

The honest counter-column. The OR-Set is not simply better, and the last column of the demo table says so. Splitting each structure's error by direction:

--- error split by direction, random gossip, random skew, 200 seeds --- gossip policy false-present false-absent total 0.00 add-wins 2.185 0.000 2.185 0.00 pure-lww 0.665 0.615 1.280 0.50 add-wins 0.880 0.000 0.880 0.50 pure-lww 0.665 0.615 1.280 3.00 add-wins 0.130 0.000 0.130 3.00 pure-lww 0.665 0.615 1.280

The OR-Set's false-absent count is 0.000 at every gossip rate. It never loses an add — not once in 1200 traces. Every error it makes is a resurrection: an element the user deleted coming back because the delete was concurrent with an add. Gossip shrinks that error toward zero. (This is the same failure lsm-tree studies from the storage side, where dropping tombstones during a partial merge hands back a deleted key. It is a real hazard and it is not this toy's headline.)

The LWW store's error is half deletion. It silently discards 0.615 adds per trace, and 60× more gossip recovers exactly none of them. Which failure is worse is a question about your application — a resurrected item in a shopping cart versus a payment that vanished — but only one of the two is a knob you can turn.

6.4 Why the LWW column cannot move

The flatness is not a statistical accident, and it has a closed form. With replica 0's clock far enough ahead, replica 0's last operation on an element always wins. So an element is wrong exactly when the trace's last operation on it was not replica 0's and replica 0's last operation on it carried the other label. With 3 replicas and P(add) = 0.55:

=== 4. 200 seeds, 6 elements, 120 ops, replica 0's clock +1000 ticks === sync_every OR-Set wrong LWW wrong 1 0.000 1.980 2 0.095 1.980 3 0.180 1.980 4 0.290 1.980 8 0.650 1.980 16 0.830 1.980 32 1.590 1.980 64 2.135 1.980 120 2.185 1.980 traces whose final add-wins value changed across those 9 schedules: 186/200 traces whose final pure-lww value changed across those 9 schedules: 0/200 closed form for the LWW column, from the ops and the clocks alone: 6 elements x P(last op isn't r0's) x P(labels differ) = 6 x 2/3 x (2 x 0.55 x 0.45) = 1.9800 measured, at every one of the 9 schedules: 1.9800

6 × 2/3 × 0.495 = 1.9800 predicted, 1.9800 measured. And the prediction is not just right on the mean: applied per trace as "replica 0's last operation on the element wins, else the last operation by anyone", it names the exact final set on 200 of 200 traces (c2_invariance.py).

Look at what is in that formula and what is not. Element count, operation distribution, replica count, clock skew. The network appears nowhere. That is the proof of the headline, and it is stronger than the measurement: the converged value of a pure-LWW store is a function of the operations and the clocks alone, so there is no message schedule that could have changed it. Which is why the sweep runs from "sync after every single operation" to "sync once at the very end" — nine schedules spanning the entire axis — and the value is literally identical on 200 of 200 traces, while the OR-Set's value changes on 186 of 200.

6.5 Three policies, three converged sets

=== 5. when is the conflict policy observable at all? === sync_every add-wins != rm-wins all three policies differ 1 0/200 0/200 2 30/200 0/200 4 80/200 7/200 8 152/200 39/200 12 185/200 74/200 40 200/200 168/200 120 200/200 176/200

At sync_every=1 the choice of conflict policy is unobservable — add-wins, rm-wins and lww produce the identical set on all 200 traces. At sync_every=40 they differ on all 200, and on 168 of them all three land on three different sets. Every one of those runs converged.

That is the practical lesson hiding behind the formalism: the policy argument your team is having is an argument about a code path that does not execute until there is a partition. It cannot be tested by a healthy cluster.

6.6 The boundary conditions — where each effect vanishes

Both effects have a clean edge, and they are at opposite ends of different axes.

The LWW store's disadvantage vanishes with a good clock. Operations are one tick apart in this trace, so the question is how the skew compares to the inter-operation interval:

--- boundary A: how small must the clock skew be for LWW to be exact? --- (ops are 1 tick apart, sync_every=8) skew pure-lww wrong traces exactly right 0 0.000 200/200 1 0.000 200/200 2 0.135 173/200 3 0.200 161/200 4 0.310 144/200

At skew 0 and skew 1 it is exactly right on 200 of 200 traces. Below one operation-interval of clock error, last-write-wins is not an approximation of the sequential answer — it is the sequential answer, at a fraction of the metadata. And in that regime the CRDT is the strictly worse structure. The control run in c1_headline.py, same gossip sweep with every clock perfect:

=== control: the same runs with every clock perfect (skew 0), 3 replicas === gossip add-wins wrong pure-lww wrong pure-lww exact 0.00 2.185 0.000 200/200 0.05 1.960 0.000 200/200 0.20 1.350 0.000 200/200 0.50 0.880 0.000 200/200 1.00 0.505 0.000 200/200 3.00 0.130 0.000 200/200

LWW is perfect at every gossip rate; the OR-Set is wrong at every gossip rate. The CRDT's entire error is the price of refusing to trust a clock. If your clock is genuinely good relative to how often the same key is contended, you are paying that price for nothing.

The OR-Set's advantage vanishes with zero concurrency:

--- boundary B: how often must you sync for the OR-Set to be exact? --- sync add-wins wrong traces exactly right (skew is 1000 and irrelevant) 1 0.000 200/200 2 0.095 181/200 3 0.180 166/200 4 0.290 153/200 8 0.650 102/200

Exactly right on 200 of 200 at sync_every=1, and — from §6.5 — the policy choice is invisible there too. If nothing is ever concurrent, the OR-Set's machinery is metadata you are paying for and never using.

The two degrade at almost the same rate against their own resource. Window 2, 3, 4: LWW 0.135 / 0.200 / 0.310 against OR-Set 0.095 / 0.180 / 0.290. One tick of clock error is worth about one operation of sync delay, which is the intuition to carry out of this page. The question for your own system is not "CRDT or LWW", it is which of those two quantities you can bound more tightly: the skew between your clocks, or the delay between your replicas.

6.7 The tests

$ python3 test_crdt.py PASS test_gossip_moves_the_orset_and_not_the_lww_store PASS test_every_run_converges_including_the_ones_that_are_wrong PASS test_the_orset_never_loses_an_add PASS test_seed_zero_trace_is_the_one_the_page_prints PASS test_seed_zero_three_schedules PASS test_e4_flips_at_skew_three PASS test_e0_flips_between_sync_every_2_and_4 PASS test_lww_value_is_identical_across_nine_sync_schedules PASS test_closed_form_predicts_the_lww_column_exactly PASS test_covers_is_load_bearing_at_sync_every_one_and_inert_at_120 PASS test_without_covers_the_set_can_never_delete PASS test_supersede_is_what_bounds_the_state PASS test_lww_is_exact_when_the_clock_is_good_enough PASS test_the_policy_is_unobservable_without_concurrency PASS test_concurrent_add_and_remove_survive_as_two_assertions PASS test_a_causally_prior_assertion_is_dropped_not_kept PASS test_merge_is_idempotent_and_commutative PASS test_pure_lww_ignores_arrival_order_entirely PASS test_demo_output_is_byte_identical_across_runs All 19 tests PASSED

Every headline number on this page is asserted by one of these, so the page cannot rot quietly: test_gossip_moves_the_orset_and_not_the_lww_store pins the 16.8× and the 15.1× and the flat (1.280, 56) at four gossip rates; test_without_covers_the_set_can_never_delete pins the 200/200 grow-only result; test_lww_value_is_identical_across_nine_sync_schedules pins the 186-versus-0.


7. Design decisions and roads not taken

7.1 The metric, and the objection to it

Everything on this page rests on one choice: correct means matching what one machine would hold if it applied the same operations in the same order. It deserves defending rather than assuming, because a CRDT purist will object, and the objection is not silly.

The objection A CRDT does not promise to reproduce a sequential execution. It promises strong eventual consistency: replicas that have received the same set of updates have the same state. There is no global "order the operations happened in" in an asynchronous system — that is the premise of the field. Two concurrent operations have no fact of the matter about which came first, so a converged value is correct by definition, and this page's "wrong" column is measuring deviation from a fiction.

That is right, and this page does not dispute it. The formal claim is true and both structures satisfy it. The measurement is a different claim.

The answer, in three parts.

First: the metric is not chosen to embarrass the CRDT. It embarrasses the CRDT more than it embarrasses the LWW store at low gossip rates — 2.185 against 1.280 — and at skew 0 it says the CRDT is worse at every gossip rate while the LWW store is perfect (§6.6). A metric rigged against timestamps would not produce that control table. What it measures is a real, directional quantity that both structures can be scored on.

Second: "no fact of the matter" is a claim about the operations, and the metric is applied to a whole trace. Most of the operations in these traces are not concurrent with anything; they are separated by many ticks and many messages. The single-node answer is only a fiction for the genuinely concurrent subset, and the toy quantifies exactly that subset: at sync_every=1 the OR-Set matches the sequential answer on 200 of 200 traces (§6.6), which is only possible because the metric and the formalism agree completely whenever there is no concurrency. The disagreement between them is confined precisely to the conflicts, which is where it should be.

Third, and this is the real defence: the user has the metric whether or not the formalism licenses it. Somebody clicked "remove from cart" and expects the item gone. They do not know what a lattice is, and "your replicas agree" is not responsive to their complaint. §6.3 splits that complaint by direction — 0.615 adds per trace silently discarded by the LWW store — and no amount of convergence answers it.

The strongest version of the argument is the §5.4 counterfactual, which needs no metric at all. Remove the coverage test and you still have a CRDT by every formal definition — commutative, associative, idempotent, strongly eventually consistent — and its value is exactly "every element ever touched" on 200 of 200 traces. It can never delete anything. If the formal property were the property that mattered, that structure would be as good as the real one. The formalism cannot tell them apart. The metric can, which is the argument for having one.

What a fair reading concedes: at low gossip rates the OR-Set's 2.185 is not a bug, it is the add-wins policy doing what it says. The number to read is not the level, it is the slope — and the slope is the entire result.

7.2 Why keep rm-wins

It costs six lines (one all instead of one any) and it buys the §6.5 result: three policies, three converged sets, and the choice between them invisible until there is a partition — 0/200 at sync_every=1 against 200/200 at 40. With only one policy the reader could reasonably conclude that a CRDT has the answer for a concurrent add and remove. It does not. It has a correctly-identified conflict and a policy you chose, and the toy should show the policy being chosen.

7.3 Why a state-based CRDT, and one-directional gossip

State-based (convergent) rather than operation-based: merge takes a whole replica, so the transport needs no delivery guarantees at all. Duplicate a message and merge is idempotent; reorder them and it is commutative; drop one and the next merge carries the same information. That is why this page has no message-loss experiment — for a state-based CRDT, loss is indistinguishable from delay, so the gossip-rate axis already measures it. test_merge_is_idempotent_and_commutative asserts the property directly.

Gossip is one-directional on purpose (§5.7). A sync barrier is a nicer picture and a worse model: it creates instants at which every replica is up to date, and a reader could suspect the result depends on them. Random ordered pairs mean no replica is ever known to be current, and the headline is unchanged.

7.4 Why every timestamp is a parameter

There is no time.time() in this file. ts is passed in by the caller, and clock skew is an explicit integer offset. That is not a testing hack retrofitted for determinism — it is the honest interface, because a timestamp in a distributed store is an input to the algorithm, not a property of the universe. Making it a parameter forces the question the toy is about: whose clock, and how wrong is it?

The payoff is that the boundary is observable instead of a coin flip. §6.2 shows e4 flipping at exactly skew 3, because the add is at t=3 and the delete at t=5. With a real clock that transition would be a flaky test. test_demo_output_is_byte_identical_across_runs runs demo.py twice and asserts the strings match — 200-seed sweeps, random gossip, random skews, and byte-identical output.

7.5 The near-miss: one RNG, and a result that was not there

This is the most useful thing that happened while building the toy, and it is a warning about a failure mode that no amount of re-reading catches.

The first version of the gossip experiment drew the operation trace and the gossip schedule from a single seeded RNG. It produced a clean, plausible, completely fabricated result: more gossip made the LWW store worse. There was a monotone trend, it held across seeds, and it had a tidy just-so explanation ready — more merges, more chances for a skewed write to propagate.

It was an artefact. Raising the gossip rate consumed more numbers from the shared stream, which shifted every subsequent draw, which changed which operations happened. The two columns were not the same workload at two gossip rates; they were two different workloads. The "trend" was the difference between unrelated traces.

What makes this worth a section is that the bug was invisible from every direction that usually works. The code was short and correct-looking. The experiment was seeded and reproducible — byte-identical on every run, which feels like rigour and is orthogonal to it. The result was directionally plausible and had a mechanism. Nothing about re-reading the code would have surfaced it.

What surfaced it was the control that should have been there from the start: hold the operations fixed and check that they are, in fact, fixed. With orng and grng separated, the fabricated trend vanished and the real result appeared in its place — the LWW column is flat, which is a much stronger claim than the fake one, and the fake one was actively hiding it.

The lesson generalises past RNGs: when a knob is supposed to vary one thing, assert that it varies only that thing. The shipped run_gossip carries the two-RNG comment (§5.7) so the next person cannot quietly recombine them, and test_gossip_moves_the_orset_and_not_the_lww_store pins the flat column at four rates, which would fail immediately if the streams were ever merged.

7.6 What is deliberately absent


8. What's simplified vs. the real thing

Replica ids are small integers and live forever. cc is a dict keyed by replica id, and a real deployment adds and removes nodes constantly. Every retired node's entry stays in every vector clock forever unless something retires it, which is a genuinely hard problem — you cannot drop an entry without risking treating an old dot as unseen, which §5.3 shows resurrects data. Riak's dotted version vectors exist substantially to bound this growth; production systems bound it by keying the causal context on coordinating replicas rather than all replicas.

The causal context is exact and unbounded. covers is a single integer comparison because a replica's dots arrive in order. Real anti-entropy delivers out of order, so the context is a set of intervals per replica, not a single high-water mark, and compaction of those intervals is a real subsystem.

No concurrency. Everything is single-threaded, and merge mutates self.el in place. A real replica takes merges from several peers while serving local writes; the read-modify-write in merge would need a lock or an immutable-state swap. This toy would be a data race in production and the mechanism it teaches would be identical.

No persistence, no failure, no partial merges. A replica that crashes mid-merge, in this toy, does not exist.

The metric needs a global observer. sequential(trace) computes the single-node answer by looking at every operation on every replica in one place. No participant in a real system can do that — which is exactly why the error this page measures is invisible in production. Nothing logs it, nothing alerts on it, every replica agrees, and every health check is green. That is worth sitting with: the failure mode being measured here is one that a real deployment has no instrument for.

Determinism is load-bearing for the write-up, not the mechanism. Timestamps are caller-supplied and two RNGs are separated (§7.5). A real system reads a clock and gets whatever NTP last told it. The toy makes clock error a dial so that §6.6's boundary is a table instead of an anecdote; production makes it an unknown, which is the whole problem.

One element, one key, no values. A real LWW store resolves conflicts per field, and a real CRDT set holds values with their own conflict rules. The toy's elements are opaque strings, which keeps the question to "in or out".


9. Check yourself

Answer before expanding. Every answer is derivable from the source or from a transcript above, and every one below was verified by running it.

Question 1

At gossip 3.00 the OR-Set is wrong on 0.130 elements and the LWW store on 1.280 — roughly 10× worse. Should you ship the OR-Set?

Answer

Not on that number alone. The 1.280 is measured with a random per-replica skew drawn from {0, 3, 7, 25, 100} ticks, against operations one tick apart. The control in §6.6 reruns the identical sweep with every clock perfect:

gossip add-wins wrong pure-lww wrong pure-lww exact 0.00 2.185 0.000 200/200 3.00 0.130 0.000 200/200

With a good clock the LWW store is exactly right on 200 of 200 traces at every gossip rate, and the OR-Set is the worse structure everywhere. The number you need first is your clock skew relative to how often the same key is contended — §6.6 measures the exchange rate at about one tick of skew per operation of sync delay.

Question 2

merge keeps a dot when dot in theirs or not other.covers(dot). Predict what happens if you delete just the dot in theirs clause — then check.

Answer

The set empties completely. Measured, 200 seeds:

merge(): drop `dot in theirs` 3.290 / 0 3.290 / 0 3.290 / 0 traces whose merged value is the EMPTY set: 200/200 mean size of the single-node answer: 3.290

3.290 is not partial degradation — it is exactly the mean size of the correct answer, because the merged value is {} on every trace. Any assertion both replicas currently hold is covered by both of them, so without the identity clause each side drops what the other still asserts, and the first merge deletes everything they agree on. The / 0 column says it converges perfectly while doing it.

Question 3

At sync_every=1, add-wins and rm-wins produce identical values on 200 of 200 traces. Does that mean the policy choice doesn't matter?

Answer

It means it is unobservable at that sync rate, which is close to the opposite. From §6.5:

sync_every add-wins != rm-wins all three policies differ 1 0/200 0/200 12 185/200 74/200 40 200/200 168/200

present() is only ever called on assertions that merge has decided are concurrent. At sync_every=1 there is never more than one surviving assertion per element, so the policy branch never decides anything. At sync_every=40 it decides on every trace, and on 168 of 200 the three policies land on three different converged sets. The policy is the code path that runs only during a partition, which is precisely why a healthy cluster cannot test it.

Question 4

The LWW store gets 56 of 200 traces exactly right at every gossip rate. Is it the same 56?

Answer

Yes, seed for seed:

traces pure-LWW got exactly right: 56 at gossip 0.00 is it the SAME set of traces at every rate? True first ten of them: [2, 3, 6, 7, 9, 11, 15, 19, 28, 34]

It has to be, and §6.4 says why. The converged value is a function of the operations and the clocks alone — the closed form names the exact final set on 200 of 200 traces — so the whole value, not just its error count, is independent of the network. Per trace, across six gossip rates, the value changed on 0 of 200. The 56 are the traces where the skewed replica's last operation on each element happens to carry the same label as the trace's last operation on it.

Question 5

You run three replicas in three regions. NTP holds your clocks to about 50ms, and the same key is written roughly once a second. Which structure, and what breaks the answer?

Answer

On these numbers, last-write-wins. The ratio that matters is clock error ÷ interval between contending operations on the same key: 50ms ÷ 1s = 0.05, well inside the regime where §6.6 measures LWW as exactly right on 200 of 200 traces (skew ≤ 1 operation-interval). Paying the OR-Set's metadata — 213.1 bytes against 131.1, growing to 453.9 as replicas stay apart — buys nothing, and §6.6's control shows it would make you worse.

What breaks it: the denominator, not the numerator. A hot key written 50 times a second puts the interval at 20ms and the ratio at 2.5, straight into the region where §6.6 measures 0.135 to 0.310 elements wrong. Contention is per-key and bursty, so "our clocks are fine" is not the question — "is any key ever written twice within our skew window" is. And the Jepsen Cassandra analysis in §10 is the case study of the numerator failing too: a millisecond-precision timestamp padded with three zeroes turned a microsecond field into millisecond collisions, and about 1% of writes were lost.

Question 6

Remove the vector-clock coverage test and error at sync_every=1 goes 0.000 → 2.710, yet every replica still agrees. What property was lost, and what was kept?

Answer

Kept: every formal CRDT property. merge is still commutative, associative and idempotent, and the converged column reads True on every row of the §5.4 table. It is still a legitimate state-based CRDT with strong eventual consistency.

Lost: the ability to delete. Not "sometimes" — at sync_every=1 the merged value is exactly the set of every element the trace ever touched, on 200 of 200 traces. The structure became a grow-only set.

The reason is the third line of the §4 merge diagram. An assertion missing from the other side means one of two things — they never saw it, or they saw it and superseded it — and only the vector clock can tell those apart. Without it, every absence reads as ignorance, so a removal can never be propagated as a removal; it only ever adds a competing assertion, and add-wins keeps the add.

This is why the toy's headline is not "CRDTs converge". Convergence survived the mechanism being deleted.


10. Further reading

Every link below was fetched and confirmed live when this was written.