FedAvg
A federated-averaging (FedAvg) CRDT: each peer trains locally on some number of examples and contributes a weight vector (see contribution), and the merged read is the count-weighted mean across all peers.
Lattice design — per-peer slots with a total order
FedAvg holds one slot per contributing ReplicaId. Each slot carries:
an
epoch(monotoneLong): a peer increments its epoch when it starts a new round.a
sampleCount(positiveLong): the number of examples the peer trained on.a
weightedSum(List<Double>):sampleCount × localWeights, pre-multiplied so merging across peers is pure addition (no division at merge time).
The piece join keeps the maximum slot per peer under a deterministic total order: (epoch, sampleCount, weightedSum lexicographically). On collision, the max always wins, regardless of which side is receiver and which is argument.
This satisfies the three lattice laws for all inputs:
Idempotent:
max(a, a) == a.Commutative:
max(a, b) == max(b, a)— max over a total order is symmetric.Associative:
max(max(a,b), c) == max(a, max(b,c)).
The total order makes the join correct even when a peer re-broadcasts at the same epoch with different content (e.g. a retrain within round 1). Only the lexicographically-largest slot survives, and every replica converges to the same one regardless of delivery order.
Reading the result
Call weights to get Σ(n_k · w_k[i]) / Σ(n_k) per coordinate. The sum is computed in canonical ReplicaId order (lexicographic) for bit-for-bit reproducibility across replicas and platforms (including wasmJs).
weights throws IllegalStateException when no peer has contributed (total count is zero) — dividing by zero would produce wrong data silently; fail loud instead.
Single-round usage (F1)
For a single training round, use the default epoch = 1L in contribution. Re-delivering the same frame is absorbed idempotently. Multiple rounds are supported by passing a monotonically increasing epoch to contribution.
Samples
val alice = ReplicaId("alice")
val bob = ReplicaId("bob")
val carol = ReplicaId("carol")
// Each peer trains locally and contributes its results.
val fromAlice = FedAvg.contribution(alice, sampleCount = 100L, localWeights = listOf(0.5, 0.3))
val fromBob = FedAvg.contribution(bob, sampleCount = 200L, localWeights = listOf(0.7, 0.1))
val fromCarol = FedAvg.contribution(carol, sampleCount = 300L, localWeights = listOf(0.9, 0.5))
// Any replica merges contributions in any order — result is the same.
val merged = FedAvg.ZERO.piece(fromAlice).piece(fromBob).piece(fromCarol)
// weights[i] = Σ(n_k * w_k[i]) / Σ(n_k)
val w = merged.weights
check(w.size == 2)
// Spot-check: (100*0.5 + 200*0.7 + 300*0.9) / (100+200+300) = 460/600 ≈ 0.7667
check(w[0] in 0.766..0.768)
// Idempotent: absorbing the same contribution again changes nothing.
check(merged.piece(fromAlice) == merged)
check(merged.piece(fromBob) == merged)
// Rides the coordination-free path — no Seam or Raft required.
val free = CoordinationFree(fromAlice).embroider(CoordinationFree(fromBob))
check(free.state == FedAvg.ZERO.piece(fromAlice).piece(fromBob))