gradientmail 0.0.1__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,839 @@
1
+ """The learner-syncer exchange: records, their keys, the JSON codec and the operations.
2
+
3
+ Keys live under the mailbox's prefix; fixed widths make byte order equal numeric order::
4
+
5
+ manifest.json Manifest, create-once
6
+ rounds/{r:010d}.json RoundRecord: commit point of r
7
+ snapshots/{r:010d}/{learner}.json Snapshot of learner for round r
8
+ claims/{r:010d}/{n:03d}.json nth claim to close round r
9
+ claims/manifest/{n:03d}.json nth claim to create the run
10
+ leases/{learner}/{n:010d}.json nth beat of learner's lease
11
+ blobs/init/{p:03d}/{sha256}{ext} Θ⁰ candidates (Manifest.init)
12
+ blobs/rounds/{r:010d}/{sha256}{ext} theta, outer and update payloads of r
13
+ blobs/snapshots/{r:010d}/{learner}/{sha256}{ext} snapshot payload
14
+
15
+ Records and payloads live under different prefixes, so ``list("rounds/", after=...)`` returns
16
+ exactly the new commits and ``list("snapshots/{r}/")`` exactly the candidates of round ``r``.
17
+ ``{ext}`` is ``.safetensors`` or ``.safetensors.zst``: the codec the writer stored the payload
18
+ with (``compression`` module), which every reader decodes by the extension alone.
19
+
20
+ Write ordering
21
+ --------------
22
+ A payload is written before the record that points at it: init payloads then the manifest, a
23
+ snapshot payload then its record, theta, outer and the update then the round record. With a
24
+ store that
25
+ lists and reads its own writes, a record a party can see always has durable payloads, and a
26
+ crash between the two writes leaves an orphan payload and no record. Snapshot orphans are
27
+ swept with their round and round-payload orphans with their round's payloads; the init
28
+ payloads of a party that crashed before the manifest write are never removed.
29
+
30
+ Idempotency
31
+ -----------
32
+ Every object is written exactly once with ``put_if_absent``. A payload key contains the sha256
33
+ of its bytes, so writing the same tensors again is a no-op and a record can never describe
34
+ bytes other than the ones it was written with. Records are keyed by identity (the manifest,
35
+ the round, the round and learner), so a retried write that finds its key taken is a success:
36
+ ``publish_snapshot`` returns, ``commit`` compares payload keys and returns, ``create_run``
37
+ adopts the manifest that won and deletes the init payloads it does not reference. Two learners
38
+ racing to initialize therefore end up on one Θ⁰, and a learner that restarts and publishes a
39
+ second snapshot for a round it had already published changes nothing: the first record stands
40
+ and the second payload is swept with the round.
41
+
42
+ Election
43
+ --------
44
+ Every node runs the syncer loop; round ``r`` is closed by the node that created the round's
45
+ first claim, a create-once object naming the claimant. The others wait for the round record
46
+ and, after a timeout measured on their own clock, create the next claim and take over. Claims
47
+ only avoid redundant work: the round record is the commit point and the tape. A node whose
48
+ record write finds the key taken reads the record back; equal payload keys mean its own
49
+ earlier attempt landed (``Committed``), different keys mean another node closed the round and
50
+ ``commit`` returns that record as ``Superseded`` for the loser to adopt. Round records are
51
+ never deleted, so ``rounds/`` is the full history of the run and the only input recovery needs.
52
+
53
+ Creating the run is elected the same way. The node that creates ``claims/manifest/001`` uploads
54
+ Θ⁰ and writes the manifest; the others poll for ``manifest.json`` instead of each uploading a
55
+ Θ⁰ of its own only to lose the race, which with bytes bodies costs the whole upload before the
56
+ 412 arrives. A waiter watches the count of init payloads as a sign of life and, when none lands
57
+ for long enough (the ``learner`` module decides how long), creates the next claim and creates
58
+ the run itself; a creator that was slow rather than dead adopts the manifest that won.
59
+
60
+ Leases
61
+ ------
62
+ A learner holds a lease while it runs. ``beat`` lists ``leases/{learner}/``, creates the beat
63
+ after the newest and deletes the rest, so the listing shows one object per learner (two during
64
+ a beat) and a restarted learner continues its own count; ``release`` deletes them all when the
65
+ learner closes. ``leases`` is one LIST of the whole prefix: the newest beat per learner. A lease
66
+ holds no time; the ``lease`` module judges liveness on the observer's own clock from how long
67
+ the newest beat has stayed the same. A learner that crashes leaves its last beat behind.
68
+
69
+ Failure modes
70
+ -------------
71
+ A listing may show a payload before its record (there is no atomicity across keys); every
72
+ reader follows records and never lists ``blobs/``. A record listed but gone by the time it is
73
+ read was swept meanwhile and is skipped. A snapshot that lands after its round closed is not
74
+ in the round record and is swept with the round; its learner's local progress on that fragment
75
+ is overwritten by the committed version. ``fetch`` returns None for a payload the sweeper has
76
+ removed (the syncer records the snapshot as ``missing``; a learner skips that fragment and
77
+ aligns with the newer version its next poll lists) and raises ``PayloadCorrupt`` when the bytes
78
+ do not hash to the record (the syncer records ``corrupt``; a learner fails, since a committed
79
+ payload that does not match its record is a store fault). Two processes publishing under one
80
+ learner id write one snapshot record per round, and the other's contribution is lost without
81
+ an error; the default id carries the process id for this reason. A malformed record raises
82
+ ``ProtocolError``, and so does a manifest or round record that has disappeared, since the
83
+ protocol never deletes either. ``MailboxUnavailable`` is retried with exponential backoff
84
+ (0.5 s doubling to 30 s) for as long as it lasts; every other error propagates.
85
+
86
+ A ``*Draft`` carries the fields a party knows before anything is written; the ``Exchange``
87
+ completes it with the payload refs it wrote and stores the full record.
88
+ """
89
+
90
+ import asyncio
91
+ import hashlib
92
+ import json
93
+ import logging
94
+ import re
95
+ import time
96
+ from collections.abc import Awaitable, Callable, Mapping, Sequence
97
+ from dataclasses import dataclass
98
+ from functools import partial
99
+ from typing import Literal, assert_never
100
+
101
+ from gradientmail.algorithms import Estimates, Participant
102
+ from gradientmail.clock import Clock
103
+ from gradientmail.compression import EXTENSION, Compression, compression_of
104
+ from gradientmail.compression import decode as decode_payload
105
+ from gradientmail.compression import encode as encode_payload
106
+ from gradientmail.mailbox import Mailbox, retrying
107
+ from gradientmail.serialization import DType, ParameterSpec, Schema, TensorBytes, pack
108
+
109
+ log = logging.getLogger(__name__)
110
+
111
+ type Rejection = Literal["stale", "zero_steps", "schema", "corrupt", "missing"]
112
+
113
+
114
+ @dataclass(frozen=True)
115
+ class PayloadRef:
116
+ key: str
117
+ sha256: str
118
+ size: int
119
+
120
+
121
+ @dataclass(frozen=True)
122
+ class ManifestDraft:
123
+ schema: Schema[DType]
124
+ plan: tuple[tuple[str, ...], ...]
125
+ algorithm: Mapping[str, object]
126
+ created_by: str
127
+
128
+
129
+ @dataclass(frozen=True)
130
+ class Manifest(ManifestDraft):
131
+ """The run as created: ``init`` holds one Θ⁰ payload per fragment of ``plan``."""
132
+
133
+ init: tuple[PayloadRef, ...]
134
+
135
+
136
+ @dataclass(frozen=True)
137
+ class SnapshotDraft:
138
+ """``final`` marks a snapshot published by ``close()``: the learner trains no further."""
139
+
140
+ learner: str
141
+ round: int
142
+ local_step: int
143
+ steps: int
144
+ tokens: int
145
+ base_version: int
146
+ rounds_seen: int
147
+ step_seconds: float
148
+ schema_digest: str
149
+ final: bool
150
+
151
+ def __post_init__(self) -> None:
152
+ if not _LEARNER_ID.fullmatch(self.learner):
153
+ raise ProtocolError(f"learner id {self.learner!r} must match [a-z0-9-]{{1,64}}")
154
+
155
+
156
+ @dataclass(frozen=True)
157
+ class Snapshot(SnapshotDraft):
158
+ payload: PayloadRef
159
+
160
+
161
+ @dataclass(frozen=True)
162
+ class RoundRecordDraft:
163
+ """``cosine`` is the mean cosine similarity between the participants' deltas over the
164
+ fragment, None with fewer than two; ``quorum_seconds`` and ``grace_seconds`` are this
165
+ round's realized waits; ``estimates`` are the timing EMAs entering the next round, so
166
+ whoever closes it continues them."""
167
+
168
+ round: int
169
+ step: int
170
+ fragment: int
171
+ version: int
172
+ participants: tuple[Participant, ...]
173
+ rejected: tuple[tuple[str, Rejection], ...]
174
+ cosine: float | None
175
+ quorum_seconds: float
176
+ grace_seconds: float
177
+ estimates: Estimates
178
+
179
+
180
+ @dataclass(frozen=True)
181
+ class CommitUpdate:
182
+ """``Θ_new - Θ_old`` at the algorithm's commit precision: a learner holding the old version
183
+ applies it to its copy instead of fetching theta and checks the result against
184
+ ``theta_digest``, the sha256 of theta packed as safetensors, whatever codec stored it."""
185
+
186
+ payload: PayloadRef
187
+ theta_digest: str
188
+
189
+
190
+ @dataclass(frozen=True)
191
+ class RoundRecord(RoundRecordDraft):
192
+ theta: PayloadRef
193
+ outer: PayloadRef
194
+ update: CommitUpdate | None
195
+
196
+
197
+ type Record = Manifest | Snapshot | RoundRecord
198
+
199
+
200
+ @dataclass(frozen=True)
201
+ class Committed:
202
+ """``commit`` wrote ``record``, now or in an earlier attempt that landed."""
203
+
204
+ record: RoundRecord
205
+
206
+
207
+ @dataclass(frozen=True)
208
+ class Superseded:
209
+ """Another node had committed the round first; ``record`` is theirs."""
210
+
211
+ record: RoundRecord
212
+
213
+
214
+ type CommitOutcome = Committed | Superseded
215
+
216
+
217
+ class ProtocolError(ValueError):
218
+ """A record that does not follow the protocol: malformed JSON, a bad field, a missing key."""
219
+
220
+
221
+ class RunConfigMismatch(ValueError):
222
+ """The run's manifest was created with a different algorithm configuration."""
223
+
224
+ expected: Mapping[str, object]
225
+ found: Mapping[str, object]
226
+
227
+ def __init__(self, expected: Mapping[str, object], found: Mapping[str, object]) -> None:
228
+ self.expected = expected
229
+ self.found = found
230
+ super().__init__(
231
+ f"run was created with a different algorithm configuration: {dict(found)!r}, "
232
+ f"this party has {dict(expected)!r}"
233
+ )
234
+
235
+
236
+ class PayloadCorrupt(Exception):
237
+ """The bytes under ``ref.key`` do not hash to ``ref.sha256``."""
238
+
239
+ ref: PayloadRef
240
+ actual: str
241
+
242
+ def __init__(self, ref: PayloadRef, actual: str) -> None:
243
+ self.ref = ref
244
+ self.actual = actual
245
+ super().__init__(f"{ref.key} hashes to {actual}, record says {ref.sha256}")
246
+
247
+
248
+ MANIFEST_KEY = "manifest.json"
249
+
250
+
251
+ def round_key(round: int) -> str:
252
+ return f"rounds/{round:010d}.json"
253
+
254
+
255
+ def snapshots_prefix(round: int) -> str:
256
+ return f"snapshots/{round:010d}/"
257
+
258
+
259
+ def snapshot_key(round: int, learner: str) -> str:
260
+ return f"{snapshots_prefix(round)}{learner}.json"
261
+
262
+
263
+ def claim_key(round: int, attempt: int) -> str:
264
+ return f"claims/{round:010d}/{attempt:03d}.json"
265
+
266
+
267
+ CREATION_CLAIMS = "claims/manifest/"
268
+
269
+
270
+ def creation_claim_key(attempt: int) -> str:
271
+ return f"{CREATION_CLAIMS}{attempt:03d}.json"
272
+
273
+
274
+ LEASES = "leases/"
275
+
276
+
277
+ def lease_prefix(learner: str) -> str:
278
+ return f"{LEASES}{learner}/"
279
+
280
+
281
+ def lease_key(learner: str, beat: int) -> str:
282
+ return f"{lease_prefix(learner)}{beat:010d}.json"
283
+
284
+
285
+ def init_blob_key(fragment: int, sha256: str) -> str:
286
+ """The payload key without its codec extension, as every ``*_blob_key`` is."""
287
+ return f"blobs/init/{fragment:03d}/{sha256}"
288
+
289
+
290
+ def round_blob_key(round: int, sha256: str) -> str:
291
+ return f"blobs/rounds/{round:010d}/{sha256}"
292
+
293
+
294
+ def snapshot_blob_key(round: int, learner: str, sha256: str) -> str:
295
+ return f"blobs/snapshots/{round:010d}/{learner}/{sha256}"
296
+
297
+
298
+ def encode(record: Record) -> bytes:
299
+ """Canonical JSON: sorted keys, no whitespace."""
300
+
301
+ def ref(payload: PayloadRef) -> Mapping[str, object]:
302
+ return {"key": payload.key, "sha256": payload.sha256, "size": payload.size}
303
+
304
+ def body() -> Mapping[str, object]:
305
+ match record:
306
+ case Manifest():
307
+ return {
308
+ "schema": [
309
+ {"name": p.name, "dtype": p.dtype, "shape": p.shape}
310
+ for p in record.schema.parameters
311
+ ],
312
+ "plan": record.plan,
313
+ "algorithm": dict(record.algorithm),
314
+ "init": [ref(payload) for payload in record.init],
315
+ "created_by": record.created_by,
316
+ }
317
+ case Snapshot():
318
+ return {
319
+ "learner": record.learner,
320
+ "round": record.round,
321
+ "local_step": record.local_step,
322
+ "steps": record.steps,
323
+ "tokens": record.tokens,
324
+ "base_version": record.base_version,
325
+ "rounds_seen": record.rounds_seen,
326
+ "step_seconds": record.step_seconds,
327
+ "schema_digest": record.schema_digest,
328
+ "final": record.final,
329
+ "payload": ref(record.payload),
330
+ }
331
+ case RoundRecord():
332
+ return {
333
+ "round": record.round,
334
+ "step": record.step,
335
+ "fragment": record.fragment,
336
+ "version": record.version,
337
+ "participants": [
338
+ {
339
+ "learner": p.learner,
340
+ "local_step": p.local_step,
341
+ "steps": p.steps,
342
+ "tokens": p.tokens,
343
+ "weight": p.weight,
344
+ }
345
+ for p in record.participants
346
+ ],
347
+ "rejected": record.rejected,
348
+ "cosine": record.cosine,
349
+ "theta": ref(record.theta),
350
+ "outer": ref(record.outer),
351
+ "update": None
352
+ if record.update is None
353
+ else {
354
+ "payload": ref(record.update.payload),
355
+ "theta_digest": record.update.theta_digest,
356
+ },
357
+ "quorum_seconds": record.quorum_seconds,
358
+ "grace_seconds": record.grace_seconds,
359
+ "estimates": {
360
+ "step": record.estimates.step,
361
+ "quorum": record.estimates.quorum,
362
+ "sync": record.estimates.sync,
363
+ },
364
+ }
365
+ case _:
366
+ assert_never(record)
367
+
368
+ return json.dumps(body(), sort_keys=True, separators=(",", ":")).encode()
369
+
370
+
371
+ def decode_manifest(data: bytes) -> Manifest:
372
+ def parameter(fields: _Fields) -> ParameterSpec[DType]:
373
+ return ParameterSpec(
374
+ fields.string("name"),
375
+ dtype(fields.values.get("dtype")),
376
+ tuple(_integer(size, "shape") for size in fields.items("shape")),
377
+ )
378
+
379
+ def dtype(value: _Json) -> DType:
380
+ match value:
381
+ case "F32" | "F16" | "BF16" as code:
382
+ return code
383
+ case other:
384
+ raise ProtocolError(f"dtype: expected F32, F16 or BF16, got {other!r}")
385
+
386
+ def schema(fields: _Fields) -> Schema[DType]:
387
+ parameters = tuple(parameter(entry) for entry in fields.each("schema"))
388
+ try:
389
+ return Schema(parameters)
390
+ except ValueError as exc:
391
+ raise ProtocolError(f"schema: {exc}") from exc
392
+
393
+ fields = _parse(data)
394
+ return Manifest(
395
+ schema=schema(fields),
396
+ plan=tuple(
397
+ tuple(_string(name, "plan") for name in _items(fragment, "plan"))
398
+ for fragment in fields.items("plan")
399
+ ),
400
+ algorithm=fields.nested("algorithm").values,
401
+ created_by=fields.string("created_by"),
402
+ init=tuple(_ref(entry) for entry in fields.each("init")),
403
+ )
404
+
405
+
406
+ def decode_snapshot(data: bytes) -> Snapshot:
407
+ fields = _parse(data)
408
+ return Snapshot(
409
+ learner=fields.string("learner"),
410
+ round=fields.integer("round"),
411
+ local_step=fields.integer("local_step"),
412
+ steps=fields.integer("steps"),
413
+ tokens=fields.integer("tokens"),
414
+ base_version=fields.integer("base_version"),
415
+ rounds_seen=fields.integer("rounds_seen"),
416
+ step_seconds=fields.number("step_seconds"),
417
+ schema_digest=fields.string("schema_digest"),
418
+ final=fields.boolean("final"),
419
+ payload=_ref(fields.nested("payload")),
420
+ )
421
+
422
+
423
+ def decode_round_record(data: bytes) -> RoundRecord:
424
+ def participant(fields: _Fields) -> Participant:
425
+ return Participant(
426
+ learner=fields.string("learner"),
427
+ local_step=fields.integer("local_step"),
428
+ steps=fields.integer("steps"),
429
+ tokens=fields.integer("tokens"),
430
+ weight=fields.number("weight"),
431
+ )
432
+
433
+ def rejection(value: _Json) -> tuple[str, Rejection]:
434
+ match value:
435
+ case [
436
+ str() as learner,
437
+ "stale" | "zero_steps" | "schema" | "corrupt" | "missing" as why,
438
+ ]:
439
+ return learner, why
440
+ case other:
441
+ raise ProtocolError(f"rejected: expected [learner, reason], got {other!r}")
442
+
443
+ def estimates(fields: _Fields) -> Estimates:
444
+ return Estimates(
445
+ step=fields.optional_number("step"),
446
+ quorum=fields.optional_number("quorum"),
447
+ sync=fields.optional_number("sync"),
448
+ )
449
+
450
+ def update(fields: _Fields) -> CommitUpdate | None:
451
+ if fields.values.get("update") is None:
452
+ return None
453
+ nested = fields.nested("update")
454
+ return CommitUpdate(_ref(nested.nested("payload")), nested.string("theta_digest"))
455
+
456
+ fields = _parse(data)
457
+ return RoundRecord(
458
+ round=fields.integer("round"),
459
+ step=fields.integer("step"),
460
+ fragment=fields.integer("fragment"),
461
+ version=fields.integer("version"),
462
+ participants=tuple(participant(entry) for entry in fields.each("participants")),
463
+ rejected=tuple(rejection(entry) for entry in fields.items("rejected")),
464
+ cosine=fields.optional_number("cosine"),
465
+ theta=_ref(fields.nested("theta")),
466
+ outer=_ref(fields.nested("outer")),
467
+ update=update(fields),
468
+ quorum_seconds=fields.number("quorum_seconds"),
469
+ grace_seconds=fields.number("grace_seconds"),
470
+ estimates=estimates(fields.nested("estimates")),
471
+ )
472
+
473
+
474
+ class Exchange:
475
+ """The protocol operations over a ``Mailbox``.
476
+
477
+ Every operation runs through ``retrying``: a ``MailboxUnavailable`` is retried with
478
+ backoff on ``clock``; anything else propagates. ``compression`` is the codec this exchange
479
+ writes payloads with; it reads payloads in any.
480
+ """
481
+
482
+ def __init__(self, mailbox: Mailbox, *, clock: Clock, compression: Compression = "off") -> None:
483
+ self._mailbox = mailbox
484
+ self._clock = clock
485
+ self._compression = compression
486
+
487
+ async def manifest(self) -> Manifest | None:
488
+ data = await self._retry(partial(self._mailbox.get, MANIFEST_KEY))
489
+ return None if data is None else decode_manifest(data)
490
+
491
+ async def create_run(
492
+ self, draft: ManifestDraft, init: Sequence[Mapping[str, TensorBytes]]
493
+ ) -> Manifest:
494
+ """Create the run from ``draft`` with ``init[p]`` as Θ⁰ of fragment ``p``.
495
+
496
+ Init payloads are written first, the manifest last. When another party created the run
497
+ first, this caller's payloads that the winner does not reference are deleted and the
498
+ winner's manifest is returned, so racing learners always start from one Θ⁰.
499
+ """
500
+ if len(init) != len(draft.plan):
501
+ raise ValueError(f"{len(draft.plan)} fragments planned but {len(init)} init payloads")
502
+ refs = tuple(
503
+ [
504
+ await self._publish(partial(init_blob_key, fragment), tensors)
505
+ for fragment, tensors in enumerate(init)
506
+ ]
507
+ )
508
+ manifest = Manifest(
509
+ schema=draft.schema,
510
+ plan=draft.plan,
511
+ algorithm=draft.algorithm,
512
+ created_by=draft.created_by,
513
+ init=refs,
514
+ )
515
+ created = await self._retry(
516
+ partial(self._mailbox.put_if_absent, MANIFEST_KEY, encode(manifest))
517
+ )
518
+ if created:
519
+ return manifest
520
+ winner = decode_manifest(await self._require(MANIFEST_KEY))
521
+ kept = {ref.key for ref in winner.init}
522
+ orphans = tuple(ref.key for ref in refs if ref.key not in kept)
523
+ await self._retry(partial(self._mailbox.delete, orphans))
524
+ return winner
525
+
526
+ async def publish_snapshot(
527
+ self, draft: SnapshotDraft, tensors: Mapping[str, TensorBytes]
528
+ ) -> Snapshot:
529
+ """Publish ``tensors`` as ``draft``'s snapshot; idempotent for one (round, learner).
530
+
531
+ Returns the snapshot as published, its payload reference included.
532
+ """
533
+ payload = await self._publish(
534
+ partial(snapshot_blob_key, draft.round, draft.learner), tensors
535
+ )
536
+ snapshot = Snapshot(
537
+ learner=draft.learner,
538
+ round=draft.round,
539
+ local_step=draft.local_step,
540
+ steps=draft.steps,
541
+ tokens=draft.tokens,
542
+ base_version=draft.base_version,
543
+ rounds_seen=draft.rounds_seen,
544
+ step_seconds=draft.step_seconds,
545
+ schema_digest=draft.schema_digest,
546
+ final=draft.final,
547
+ payload=payload,
548
+ )
549
+ # False means this (round, learner) already has a record: an earlier attempt landed.
550
+ await self._retry(
551
+ partial(
552
+ self._mailbox.put_if_absent,
553
+ snapshot_key(draft.round, draft.learner),
554
+ encode(snapshot),
555
+ )
556
+ )
557
+ return snapshot
558
+
559
+ async def snapshots(self, round: int) -> Sequence[Snapshot]:
560
+ return await self._records(snapshots_prefix(round), decode_snapshot)
561
+
562
+ async def publishers(self, round: int) -> frozenset[str]:
563
+ """The learners with a snapshot record in ``round``, from the listing alone."""
564
+ prefix = snapshots_prefix(round)
565
+ keys = await self._retry(partial(self._mailbox.list, prefix))
566
+ return frozenset(key[len(prefix) : -len(".json")] for key in keys)
567
+
568
+ async def claim(self, round: int, attempt: int, learner: str) -> bool:
569
+ """Create the ``attempt``-th claim on round ``round``; True when it is ``learner``'s."""
570
+ return await self._claim(claim_key(round, attempt), learner)
571
+
572
+ async def claims(self, round: int) -> Sequence[str]:
573
+ """The claimants of round ``round`` in attempt order."""
574
+ return await self._claimants(f"claims/{round:010d}/")
575
+
576
+ async def claim_creation(self, attempt: int, learner: str) -> bool:
577
+ """Create the ``attempt``-th claim on creating the run; True when it is ``learner``'s."""
578
+ return await self._claim(creation_claim_key(attempt), learner)
579
+
580
+ async def creation_claims(self) -> Sequence[str]:
581
+ """The claimants on creating the run, in attempt order."""
582
+ return await self._claimants(CREATION_CLAIMS)
583
+
584
+ async def init_uploaded(self) -> int:
585
+ """How many Θ⁰ payloads exist so far: a creator's sign of life, counted and never read."""
586
+ return len(await self._retry(partial(self._mailbox.list, "blobs/init/")))
587
+
588
+ async def beat(self, learner: str) -> None:
589
+ """Renew ``learner``'s lease: create the beat after its newest and delete the older ones."""
590
+ older = await self._retry(partial(self._mailbox.list, lease_prefix(learner)))
591
+ beat = max((_lease_of(key)[1] for key in older), default=-1) + 1
592
+ # False means this beat landed in an earlier attempt whose response was lost.
593
+ await self._retry(partial(self._mailbox.put_if_absent, lease_key(learner, beat), b"{}"))
594
+ await self._retry(partial(self._mailbox.delete, older))
595
+
596
+ async def release(self, learner: str) -> None:
597
+ """Delete ``learner``'s lease: it is leaving the run."""
598
+ keys = await self._retry(partial(self._mailbox.list, lease_prefix(learner)))
599
+ await self._retry(partial(self._mailbox.delete, keys))
600
+
601
+ async def leases(self) -> Mapping[str, int]:
602
+ """The newest beat of every learner holding a lease, from one listing."""
603
+ keys = await self._retry(partial(self._mailbox.list, LEASES))
604
+ # Keys come in byte order and beats have a fixed width, so a learner's last key is its
605
+ # newest beat.
606
+ return dict(_lease_of(key) for key in keys)
607
+
608
+ async def rounds_after(self, round: int) -> Sequence[RoundRecord]:
609
+ """Round records newer than ``round`` in round order; ``-1`` means all of them."""
610
+ after = round_key(round) if round >= 0 else ""
611
+ return await self._records("rounds/", decode_round_record, after=after)
612
+
613
+ async def fetch(self, ref: PayloadRef) -> Mapping[str, TensorBytes] | None:
614
+ """The tensors behind ``ref``; None once swept, ``PayloadCorrupt`` on a sha mismatch."""
615
+ compression = compression_of(ref.key)
616
+ if compression is None:
617
+ raise ProtocolError(f"{ref.key} names no payload codec")
618
+ started = time.perf_counter()
619
+ blob = await self._retry(partial(self._mailbox.get, ref.key))
620
+ if blob is None:
621
+ return None
622
+ log.debug("got %s: %d bytes in %.1f s", ref.key, len(blob), time.perf_counter() - started)
623
+ actual = _sha256(blob)
624
+ if actual != ref.sha256:
625
+ raise PayloadCorrupt(ref, actual)
626
+ return decode_payload(blob, compression)
627
+
628
+ async def commit(
629
+ self,
630
+ draft: RoundRecordDraft,
631
+ theta: Mapping[str, TensorBytes],
632
+ outer: Mapping[str, TensorBytes],
633
+ update: Mapping[str, TensorBytes] | None = None,
634
+ ) -> CommitOutcome:
635
+ """Commit round ``draft.round``: theta, outer and the update, if any, then the record.
636
+
637
+ A record already present with the same payload keys is this node's own earlier attempt
638
+ and counts as ``Committed``; different keys mean another node closed the round, and its
639
+ record comes back as ``Superseded``.
640
+ """
641
+ blob_key = partial(round_blob_key, draft.round)
642
+ theta_ref = await self._publish(blob_key, theta)
643
+ outer_ref = await self._publish(blob_key, outer)
644
+ update_ref = (
645
+ None
646
+ if update is None
647
+ else CommitUpdate(await self._publish(blob_key, update), theta_digest(theta))
648
+ )
649
+ record = RoundRecord(
650
+ round=draft.round,
651
+ step=draft.step,
652
+ fragment=draft.fragment,
653
+ version=draft.version,
654
+ participants=draft.participants,
655
+ rejected=draft.rejected,
656
+ cosine=draft.cosine,
657
+ quorum_seconds=draft.quorum_seconds,
658
+ grace_seconds=draft.grace_seconds,
659
+ estimates=draft.estimates,
660
+ theta=theta_ref,
661
+ outer=outer_ref,
662
+ update=update_ref,
663
+ )
664
+ key = round_key(draft.round)
665
+ created = await self._retry(partial(self._mailbox.put_if_absent, key, encode(record)))
666
+ if created:
667
+ return Committed(record)
668
+ existing = decode_round_record(await self._require(key))
669
+ if _payload_keys(existing) == _payload_keys(record):
670
+ return Committed(existing)
671
+ return Superseded(existing)
672
+
673
+ async def sweep_snapshots(self, round: int) -> None:
674
+ """Delete round ``round``'s snapshot records, payloads and claims, orphans included."""
675
+ records = await self._retry(partial(self._mailbox.list, f"snapshots/{round:010d}/"))
676
+ payloads = await self._retry(partial(self._mailbox.list, f"blobs/snapshots/{round:010d}/"))
677
+ claims = await self._retry(partial(self._mailbox.list, f"claims/{round:010d}/"))
678
+ await self._retry(partial(self._mailbox.delete, (*records, *payloads, *claims)))
679
+
680
+ async def sweep_blobs(self, round: int) -> None:
681
+ """Delete round ``round``'s theta and outer payloads; its record is never deleted."""
682
+ if round < 0:
683
+ return
684
+ payloads = await self._retry(partial(self._mailbox.list, f"blobs/rounds/{round:010d}/"))
685
+ await self._retry(partial(self._mailbox.delete, payloads))
686
+
687
+ async def _publish(
688
+ self, key_of: Callable[[str], str], tensors: Mapping[str, TensorBytes]
689
+ ) -> PayloadRef:
690
+ blob = encode_payload(tensors, self._compression)
691
+ sha256 = _sha256(blob)
692
+ ref = PayloadRef(key_of(sha256) + EXTENSION[self._compression], sha256, len(blob))
693
+ started = time.perf_counter()
694
+ # False means identical bytes are already there: the key contains their hash.
695
+ await self._retry(partial(self._mailbox.put_if_absent, ref.key, blob))
696
+ log.debug("put %s: %d bytes in %.1f s", ref.key, len(blob), time.perf_counter() - started)
697
+ return ref
698
+
699
+ async def _claim(self, key: str, learner: str) -> bool:
700
+ body = json.dumps({"learner": learner}, sort_keys=True, separators=(",", ":")).encode()
701
+ if await self._retry(partial(self._mailbox.put_if_absent, key, body)):
702
+ return True
703
+ return _parse(await self._require(key)).string("learner") == learner
704
+
705
+ async def _claimants(self, prefix: str) -> Sequence[str]:
706
+ return await self._records(prefix, lambda data: _parse(data).string("learner"))
707
+
708
+ async def _records[T](
709
+ self, prefix: str, decode: Callable[[bytes], T], *, after: str = ""
710
+ ) -> Sequence[T]:
711
+ keys = await self._retry(partial(self._mailbox.list, prefix, after=after))
712
+ async with asyncio.TaskGroup() as group:
713
+ reads = [
714
+ group.create_task(self._retry(partial(self._mailbox.get, key))) for key in keys
715
+ ]
716
+ # A record listed but gone by the time it is read was swept meanwhile; skip it.
717
+ return tuple(decode(data) for read in reads if (data := read.result()) is not None)
718
+
719
+ async def _require(self, key: str) -> bytes:
720
+ data = await self._retry(partial(self._mailbox.get, key))
721
+ if data is None:
722
+ raise ProtocolError(f"{key} exists but could not be read back")
723
+ return data
724
+
725
+ async def _retry[T](self, op: Callable[[], Awaitable[T]]) -> T:
726
+ return await retrying(op, clock=self._clock)
727
+
728
+
729
+ type _Json = bool | int | float | str | list[_Json] | dict[str, _Json] | None
730
+
731
+ _LEARNER_ID = re.compile(r"[a-z0-9-]{1,64}")
732
+ _LEASE_KEY = re.compile(rf"{LEASES}({_LEARNER_ID.pattern})/(\d{{10}})\.json")
733
+
734
+
735
+ def _lease_of(key: str) -> tuple[str, int]:
736
+ match = _LEASE_KEY.fullmatch(key)
737
+ if match is None:
738
+ raise ProtocolError(f"{key} is not a lease key")
739
+ return match[1], int(match[2])
740
+
741
+
742
+ @dataclass(frozen=True)
743
+ class _Fields:
744
+ """Typed access to one decoded JSON object; every mismatch is a ``ProtocolError``."""
745
+
746
+ values: Mapping[str, _Json]
747
+
748
+ def string(self, key: str) -> str:
749
+ return _string(self.values.get(key), key)
750
+
751
+ def integer(self, key: str) -> int:
752
+ return _integer(self.values.get(key), key)
753
+
754
+ def boolean(self, key: str) -> bool:
755
+ match self.values.get(key):
756
+ case bool() as value:
757
+ return value
758
+ case other:
759
+ raise ProtocolError(f"{key}: expected a boolean, got {other!r}")
760
+
761
+ def number(self, key: str) -> float:
762
+ match self.values.get(key):
763
+ case int() | float() as value:
764
+ return float(value)
765
+ case other:
766
+ raise ProtocolError(f"{key}: expected a number, got {other!r}")
767
+
768
+ def optional_number(self, key: str) -> float | None:
769
+ return None if self.values.get(key) is None else self.number(key)
770
+
771
+ def items(self, key: str) -> Sequence[_Json]:
772
+ return _items(self.values.get(key), key)
773
+
774
+ def nested(self, key: str) -> "_Fields":
775
+ return _object(self.values.get(key), key)
776
+
777
+ def each(self, key: str) -> Sequence["_Fields"]:
778
+ return tuple(_object(item, key) for item in self.items(key))
779
+
780
+
781
+ def _parse(data: bytes) -> _Fields:
782
+ try:
783
+ parsed: _Json = json.loads(data)
784
+ except ValueError as exc:
785
+ raise ProtocolError("record is not JSON") from exc
786
+ return _object(parsed, "record")
787
+
788
+
789
+ def _object(value: _Json, where: str) -> _Fields:
790
+ match value:
791
+ case dict() as values:
792
+ return _Fields(values)
793
+ case other:
794
+ raise ProtocolError(f"{where}: expected an object, got {other!r}")
795
+
796
+
797
+ def _items(value: _Json, where: str) -> Sequence[_Json]:
798
+ match value:
799
+ case list() as items:
800
+ return items
801
+ case other:
802
+ raise ProtocolError(f"{where}: expected an array, got {other!r}")
803
+
804
+
805
+ def _string(value: _Json, where: str) -> str:
806
+ match value:
807
+ case str() as text:
808
+ return text
809
+ case other:
810
+ raise ProtocolError(f"{where}: expected a string, got {other!r}")
811
+
812
+
813
+ def _integer(value: _Json, where: str) -> int:
814
+ match value:
815
+ case int() as number:
816
+ return number
817
+ case other:
818
+ raise ProtocolError(f"{where}: expected an integer, got {other!r}")
819
+
820
+
821
+ def _ref(fields: _Fields) -> PayloadRef:
822
+ return PayloadRef(fields.string("key"), fields.string("sha256"), fields.integer("size"))
823
+
824
+
825
+ def _payload_keys(record: RoundRecord) -> tuple[str, str, str | None]:
826
+ return (
827
+ record.theta.key,
828
+ record.outer.key,
829
+ None if record.update is None else record.update.payload.key,
830
+ )
831
+
832
+
833
+ def theta_digest(theta: Mapping[str, TensorBytes]) -> str:
834
+ """The sha256 of ``theta`` packed as safetensors: the same whatever codec stores it."""
835
+ return _sha256(pack(theta))
836
+
837
+
838
+ def _sha256(blob: bytes) -> str:
839
+ return hashlib.sha256(blob).hexdigest()