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 (monotone Long): a peer increments its epoch when it starts a new round.

  • a sampleCount (positive Long): 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))

Types

Link copied to clipboard
object Companion

Properties

Link copied to clipboard

The count-weighted mean of all peer contributions, per coordinate.

Functions

Link copied to clipboard
open fun causalDots(): Set<Dot>
Link copied to clipboard
Link copied to clipboard
open operator override fun equals(other: Any?): Boolean
Link copied to clipboard
open override fun hashCode(): Int
Link copied to clipboard
open override fun piece(other: FedAvg): FedAvg

The join: per-peer union keeping the max slot (by total order) on collisions.

Link copied to clipboard
open override fun toString(): String