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.
- gradientmail/__init__.py +59 -0
- gradientmail/adapters/__init__.py +174 -0
- gradientmail/adapters/mlx.py +252 -0
- gradientmail/adapters/torch.py +205 -0
- gradientmail/algorithms/__init__.py +279 -0
- gradientmail/algorithms/diloco.py +521 -0
- gradientmail/clock.py +22 -0
- gradientmail/compression.py +80 -0
- gradientmail/events.py +478 -0
- gradientmail/learner.py +891 -0
- gradientmail/lease.py +44 -0
- gradientmail/mailbox/__init__.py +57 -0
- gradientmail/mailbox/memory.py +39 -0
- gradientmail/mailbox/s3.py +210 -0
- gradientmail/patterns/__init__.py +4 -0
- gradientmail/patterns/barrier.py +67 -0
- gradientmail/patterns/shared_queue.py +104 -0
- gradientmail/protocol.py +839 -0
- gradientmail/serialization.py +241 -0
- gradientmail/session.py +195 -0
- gradientmail/syncer.py +528 -0
- gradientmail-0.0.1.dist-info/METADATA +142 -0
- gradientmail-0.0.1.dist-info/RECORD +25 -0
- gradientmail-0.0.1.dist-info/WHEEL +4 -0
- gradientmail-0.0.1.dist-info/licenses/LICENSE +21 -0
gradientmail/events.py
ADDED
|
@@ -0,0 +1,478 @@
|
|
|
1
|
+
"""What a node reports while it runs, as values, and the callbacks that receive them.
|
|
2
|
+
|
|
3
|
+
Every report the learner and the syncer make is an ``Event``: one frozen dataclass per kind,
|
|
4
|
+
each naming the ``learner`` it happened on, since one process may host several nodes. A
|
|
5
|
+
``Callback`` receives every event of its session on the thread that produced it: the caller's
|
|
6
|
+
for the join, the training thread for aligning and closing, the communication thread for
|
|
7
|
+
publishing, polling and the lease, the syncer thread for rounds. One callback may therefore run
|
|
8
|
+
on several threads at once, and one that keeps state guards it. The last two threads run event
|
|
9
|
+
loops, so a callback that blocks stalls the node's polling or its rounds, as a slow logging
|
|
10
|
+
handler does; one that does I/O hands the event to a thread of its own. ``combined`` makes one
|
|
11
|
+
callback of several; ``log_events`` is the one a session runs by default.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
import logging
|
|
15
|
+
from collections.abc import Callable
|
|
16
|
+
from dataclasses import dataclass
|
|
17
|
+
from typing import Literal, assert_never
|
|
18
|
+
|
|
19
|
+
from gradientmail.protocol import RoundRecord, RoundRecordDraft
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"AwaitingCreator",
|
|
23
|
+
"Callback",
|
|
24
|
+
"Closed",
|
|
25
|
+
"Closing",
|
|
26
|
+
"CreatingRun",
|
|
27
|
+
"Event",
|
|
28
|
+
"FollowingRound",
|
|
29
|
+
"FragmentAligned",
|
|
30
|
+
"GaveUp",
|
|
31
|
+
"Joined",
|
|
32
|
+
"LearnerEvent",
|
|
33
|
+
"LeaseReleased",
|
|
34
|
+
"PollFailed",
|
|
35
|
+
"PublishCancelled",
|
|
36
|
+
"PublishFailed",
|
|
37
|
+
"Recovered",
|
|
38
|
+
"RecoveryFailed",
|
|
39
|
+
"ReleaseFailed",
|
|
40
|
+
"RoundClaimed",
|
|
41
|
+
"RoundClosed",
|
|
42
|
+
"RoundCommitted",
|
|
43
|
+
"RoundFailed",
|
|
44
|
+
"RoundRecorded",
|
|
45
|
+
"RoundSuperseded",
|
|
46
|
+
"SnapshotPublished",
|
|
47
|
+
"SyncerEvent",
|
|
48
|
+
"SyncerStopped",
|
|
49
|
+
"Takeover",
|
|
50
|
+
"TookOverCreation",
|
|
51
|
+
"TookOverRound",
|
|
52
|
+
"combined",
|
|
53
|
+
"log_events",
|
|
54
|
+
]
|
|
55
|
+
|
|
56
|
+
log = logging.getLogger(__name__)
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@dataclass(frozen=True)
|
|
60
|
+
class CreatingRun:
|
|
61
|
+
"""This node holds the creation claim and is uploading Θ⁰ of ``fragments`` fragments."""
|
|
62
|
+
|
|
63
|
+
learner: str
|
|
64
|
+
fragments: int
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
@dataclass(frozen=True)
|
|
68
|
+
class AwaitingCreator:
|
|
69
|
+
"""``claimants`` hold the creation claim; this node polls for the manifest."""
|
|
70
|
+
|
|
71
|
+
learner: str
|
|
72
|
+
claimants: tuple[str, ...]
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
@dataclass(frozen=True)
|
|
76
|
+
class TookOverCreation:
|
|
77
|
+
"""No init payload landed for ``stalled`` seconds; this node took the next creation claim."""
|
|
78
|
+
|
|
79
|
+
learner: str
|
|
80
|
+
stalled: float
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
@dataclass(frozen=True)
|
|
84
|
+
class Joined:
|
|
85
|
+
"""The model holds the newest committed version of every fragment of the run ``created_by``
|
|
86
|
+
created; ``round`` is the newest committed round, -1 on a fresh run."""
|
|
87
|
+
|
|
88
|
+
learner: str
|
|
89
|
+
created_by: str
|
|
90
|
+
fragments: int
|
|
91
|
+
round: int
|
|
92
|
+
|
|
93
|
+
|
|
94
|
+
@dataclass(frozen=True)
|
|
95
|
+
class FragmentAligned:
|
|
96
|
+
"""``fragment`` now holds ``version``, committed by ``round``, applied at ``local_step``;
|
|
97
|
+
``lag_steps`` is how many steps that is after this learner's snapshot in the round, None
|
|
98
|
+
when it took no part in it."""
|
|
99
|
+
|
|
100
|
+
learner: str
|
|
101
|
+
round: int
|
|
102
|
+
fragment: int
|
|
103
|
+
version: int
|
|
104
|
+
local_step: int
|
|
105
|
+
lag_steps: int | None
|
|
106
|
+
|
|
107
|
+
|
|
108
|
+
@dataclass(frozen=True)
|
|
109
|
+
class SnapshotPublished:
|
|
110
|
+
"""The delta of ``fragment`` for ``round``, ``steps`` steps and ``tokens`` tokens, is in the
|
|
111
|
+
store: ``size`` bytes in ``seconds``."""
|
|
112
|
+
|
|
113
|
+
learner: str
|
|
114
|
+
round: int
|
|
115
|
+
fragment: int
|
|
116
|
+
steps: int
|
|
117
|
+
tokens: int
|
|
118
|
+
size: int
|
|
119
|
+
seconds: float
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
@dataclass(frozen=True)
|
|
123
|
+
class PublishFailed:
|
|
124
|
+
"""Publishing ``round`` failed; its steps stay counted and go out with the fragment's next
|
|
125
|
+
round."""
|
|
126
|
+
|
|
127
|
+
learner: str
|
|
128
|
+
round: int
|
|
129
|
+
error: Exception
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
@dataclass(frozen=True)
|
|
133
|
+
class PollFailed:
|
|
134
|
+
"""A receive iteration failed; the next poll retries it."""
|
|
135
|
+
|
|
136
|
+
learner: str
|
|
137
|
+
error: Exception
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
@dataclass(frozen=True)
|
|
141
|
+
class RoundRecorded:
|
|
142
|
+
"""The receive loop listed ``record``, whichever node committed it. Every node reports every
|
|
143
|
+
round committed after it joined, whether or not it aligns with that version: a newer one of
|
|
144
|
+
the same fragment in the same listing replaces it."""
|
|
145
|
+
|
|
146
|
+
learner: str
|
|
147
|
+
record: RoundRecord
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
@dataclass(frozen=True)
|
|
151
|
+
class Closing:
|
|
152
|
+
"""``close()`` began: publishing the steps since each fragment's last round when
|
|
153
|
+
``flushing``, otherwise waiting for the rounds in flight."""
|
|
154
|
+
|
|
155
|
+
learner: str
|
|
156
|
+
flushing: bool
|
|
157
|
+
|
|
158
|
+
|
|
159
|
+
@dataclass(frozen=True)
|
|
160
|
+
class Closed:
|
|
161
|
+
"""Every round this learner published, up to ``round``, is committed and the model is
|
|
162
|
+
aligned with it."""
|
|
163
|
+
|
|
164
|
+
learner: str
|
|
165
|
+
round: int
|
|
166
|
+
|
|
167
|
+
|
|
168
|
+
@dataclass(frozen=True)
|
|
169
|
+
class GaveUp:
|
|
170
|
+
"""``close()`` stopped waiting for ``round`` after nothing landed for ``waited`` seconds;
|
|
171
|
+
the model is aligned up to round ``aligned``."""
|
|
172
|
+
|
|
173
|
+
learner: str
|
|
174
|
+
round: int
|
|
175
|
+
waited: float
|
|
176
|
+
aligned: int
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
@dataclass(frozen=True)
|
|
180
|
+
class PublishCancelled:
|
|
181
|
+
"""The communication thread was still publishing ``waited`` seconds after the last rounds
|
|
182
|
+
and was cancelled."""
|
|
183
|
+
|
|
184
|
+
learner: str
|
|
185
|
+
waited: float
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
@dataclass(frozen=True)
|
|
189
|
+
class LeaseReleased:
|
|
190
|
+
learner: str
|
|
191
|
+
|
|
192
|
+
|
|
193
|
+
@dataclass(frozen=True)
|
|
194
|
+
class ReleaseFailed:
|
|
195
|
+
learner: str
|
|
196
|
+
error: Exception
|
|
197
|
+
|
|
198
|
+
|
|
199
|
+
@dataclass(frozen=True)
|
|
200
|
+
class Recovered:
|
|
201
|
+
"""The syncer rebuilt a closer's state from the store; ``round`` is the next to run."""
|
|
202
|
+
|
|
203
|
+
learner: str
|
|
204
|
+
round: int
|
|
205
|
+
|
|
206
|
+
|
|
207
|
+
@dataclass(frozen=True)
|
|
208
|
+
class RecoveryFailed:
|
|
209
|
+
"""Rebuilding a closer's state failed; it is retried in ``retry_in`` seconds."""
|
|
210
|
+
|
|
211
|
+
learner: str
|
|
212
|
+
error: Exception
|
|
213
|
+
retry_in: float
|
|
214
|
+
|
|
215
|
+
|
|
216
|
+
@dataclass(frozen=True)
|
|
217
|
+
class RoundClaimed:
|
|
218
|
+
"""This node made the first claim on ``round`` and closes it."""
|
|
219
|
+
|
|
220
|
+
learner: str
|
|
221
|
+
round: int
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
@dataclass(frozen=True)
|
|
225
|
+
class FollowingRound:
|
|
226
|
+
"""``claimants`` hold ``round``; this node waits for its record and takes over once the last
|
|
227
|
+
claimant's lease ends or no record lands within ``timeout`` seconds."""
|
|
228
|
+
|
|
229
|
+
learner: str
|
|
230
|
+
round: int
|
|
231
|
+
claimants: tuple[str, ...]
|
|
232
|
+
timeout: float
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
type Takeover = Literal["lease_gone", "no_record"]
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
@dataclass(frozen=True)
|
|
239
|
+
class TookOverRound:
|
|
240
|
+
"""This node took the next claim on ``round``: the last of ``claimants`` lost its lease, or
|
|
241
|
+
no record landed within ``timeout`` seconds."""
|
|
242
|
+
|
|
243
|
+
learner: str
|
|
244
|
+
round: int
|
|
245
|
+
claimants: tuple[str, ...]
|
|
246
|
+
cause: Takeover
|
|
247
|
+
timeout: float
|
|
248
|
+
|
|
249
|
+
|
|
250
|
+
@dataclass(frozen=True)
|
|
251
|
+
class RoundClosed:
|
|
252
|
+
"""This node closed ``draft.round`` and commits it next; the participants with their weights,
|
|
253
|
+
the rejected snapshots and the realized waits are in ``draft``."""
|
|
254
|
+
|
|
255
|
+
learner: str
|
|
256
|
+
draft: RoundRecordDraft
|
|
257
|
+
|
|
258
|
+
|
|
259
|
+
@dataclass(frozen=True)
|
|
260
|
+
class RoundCommitted:
|
|
261
|
+
"""This node wrote ``record``, its payloads and the record itself in ``seconds``."""
|
|
262
|
+
|
|
263
|
+
learner: str
|
|
264
|
+
record: RoundRecord
|
|
265
|
+
seconds: float
|
|
266
|
+
|
|
267
|
+
|
|
268
|
+
@dataclass(frozen=True)
|
|
269
|
+
class RoundSuperseded:
|
|
270
|
+
"""Another node committed the round first; ``record`` is theirs and this node follows on
|
|
271
|
+
from it."""
|
|
272
|
+
|
|
273
|
+
learner: str
|
|
274
|
+
record: RoundRecord
|
|
275
|
+
|
|
276
|
+
|
|
277
|
+
@dataclass(frozen=True)
|
|
278
|
+
class RoundFailed:
|
|
279
|
+
"""Closing or following ``round`` failed; it is retried as after a restart in ``retry_in``
|
|
280
|
+
seconds."""
|
|
281
|
+
|
|
282
|
+
learner: str
|
|
283
|
+
round: int
|
|
284
|
+
error: Exception
|
|
285
|
+
retry_in: float
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
@dataclass(frozen=True)
|
|
289
|
+
class SyncerStopped:
|
|
290
|
+
"""The syncer thread died of ``error``; the next ``Session.step()`` raises it."""
|
|
291
|
+
|
|
292
|
+
learner: str
|
|
293
|
+
error: BaseException
|
|
294
|
+
|
|
295
|
+
|
|
296
|
+
type LearnerEvent = (
|
|
297
|
+
CreatingRun
|
|
298
|
+
| AwaitingCreator
|
|
299
|
+
| TookOverCreation
|
|
300
|
+
| Joined
|
|
301
|
+
| FragmentAligned
|
|
302
|
+
| SnapshotPublished
|
|
303
|
+
| PublishFailed
|
|
304
|
+
| PollFailed
|
|
305
|
+
| RoundRecorded
|
|
306
|
+
| Closing
|
|
307
|
+
| Closed
|
|
308
|
+
| GaveUp
|
|
309
|
+
| PublishCancelled
|
|
310
|
+
| LeaseReleased
|
|
311
|
+
| ReleaseFailed
|
|
312
|
+
)
|
|
313
|
+
type SyncerEvent = (
|
|
314
|
+
Recovered
|
|
315
|
+
| RecoveryFailed
|
|
316
|
+
| RoundClaimed
|
|
317
|
+
| FollowingRound
|
|
318
|
+
| TookOverRound
|
|
319
|
+
| RoundClosed
|
|
320
|
+
| RoundCommitted
|
|
321
|
+
| RoundSuperseded
|
|
322
|
+
| RoundFailed
|
|
323
|
+
| SyncerStopped
|
|
324
|
+
)
|
|
325
|
+
type Event = LearnerEvent | SyncerEvent
|
|
326
|
+
type Callback = Callable[[Event], None]
|
|
327
|
+
|
|
328
|
+
|
|
329
|
+
def combined(*callbacks: Callback) -> Callback:
|
|
330
|
+
"""One callback that hands every event to each of ``callbacks``, in order.
|
|
331
|
+
|
|
332
|
+
A callback that raises is logged and the rest still receive the event: observing a run
|
|
333
|
+
never changes it, so a failing sink cannot fail a publish or retry a round.
|
|
334
|
+
"""
|
|
335
|
+
|
|
336
|
+
def notify(event: Event) -> None:
|
|
337
|
+
for callback in callbacks:
|
|
338
|
+
try:
|
|
339
|
+
callback(event)
|
|
340
|
+
except Exception:
|
|
341
|
+
log.exception("%s: a callback failed on %s", event.learner, type(event).__name__)
|
|
342
|
+
|
|
343
|
+
return notify
|
|
344
|
+
|
|
345
|
+
|
|
346
|
+
def log_events(event: Event) -> None:
|
|
347
|
+
"""One line per event on the ``gradientmail.events`` logger: failures at ERROR with their
|
|
348
|
+
traceback, takeovers and give-ups at WARNING, the rest at INFO. ``RoundRecorded`` is not
|
|
349
|
+
logged; the alignment or the commit it leads to is."""
|
|
350
|
+
match event:
|
|
351
|
+
case CreatingRun(learner, fragments):
|
|
352
|
+
log.info("%s: creating the run: uploading Θ⁰ of %d fragments", learner, fragments)
|
|
353
|
+
case AwaitingCreator(learner, claimants):
|
|
354
|
+
log.info("%s: waiting for %s to create the run", learner, ", ".join(claimants))
|
|
355
|
+
case TookOverCreation(learner, stalled):
|
|
356
|
+
log.warning("%s: no init payload landed for %.0f s; creating the run", learner, stalled)
|
|
357
|
+
case Joined(learner, created_by, fragments, round):
|
|
358
|
+
log.info(
|
|
359
|
+
"%s: joined the run created by %s: %d fragments, newest round %d",
|
|
360
|
+
learner,
|
|
361
|
+
created_by,
|
|
362
|
+
fragments,
|
|
363
|
+
round,
|
|
364
|
+
)
|
|
365
|
+
case FragmentAligned(learner, round, fragment, version, local_step, lag_steps):
|
|
366
|
+
log.info(
|
|
367
|
+
"%s: fragment %d aligned with version %d (round %d), at local step %d%s",
|
|
368
|
+
learner,
|
|
369
|
+
fragment,
|
|
370
|
+
version,
|
|
371
|
+
round,
|
|
372
|
+
local_step,
|
|
373
|
+
"" if lag_steps is None else f", {lag_steps} steps after its snapshot",
|
|
374
|
+
)
|
|
375
|
+
case SnapshotPublished(learner, round, fragment, steps, tokens, size, seconds):
|
|
376
|
+
log.info(
|
|
377
|
+
"%s: round %d published: fragment %d, %d steps, %d tokens, %d bytes, %.1f s",
|
|
378
|
+
learner,
|
|
379
|
+
round,
|
|
380
|
+
fragment,
|
|
381
|
+
steps,
|
|
382
|
+
tokens,
|
|
383
|
+
size,
|
|
384
|
+
seconds,
|
|
385
|
+
)
|
|
386
|
+
case PublishFailed(learner, round, error):
|
|
387
|
+
log.error(
|
|
388
|
+
"%s: round %d: publishing failed; its steps go out with the fragment's next round",
|
|
389
|
+
learner,
|
|
390
|
+
round,
|
|
391
|
+
exc_info=error,
|
|
392
|
+
)
|
|
393
|
+
case PollFailed(learner, error):
|
|
394
|
+
log.error("%s: poll failed; retrying", learner, exc_info=error)
|
|
395
|
+
case RoundRecorded():
|
|
396
|
+
pass
|
|
397
|
+
case Closing(learner, flushing):
|
|
398
|
+
log.info(
|
|
399
|
+
"%s: closing: %s",
|
|
400
|
+
learner,
|
|
401
|
+
"publishing the steps since the last rounds"
|
|
402
|
+
if flushing
|
|
403
|
+
else "waiting for the rounds in flight",
|
|
404
|
+
)
|
|
405
|
+
case Closed(learner, round):
|
|
406
|
+
log.info("%s: closed: aligned with every round up to %d", learner, round)
|
|
407
|
+
case GaveUp(learner, round, waited, aligned):
|
|
408
|
+
log.warning(
|
|
409
|
+
"%s: giving up on round %d: nothing landed for %.0f s; aligned up to round %d",
|
|
410
|
+
learner,
|
|
411
|
+
round,
|
|
412
|
+
waited,
|
|
413
|
+
aligned,
|
|
414
|
+
)
|
|
415
|
+
case PublishCancelled(learner, waited):
|
|
416
|
+
log.warning(
|
|
417
|
+
"%s: still publishing %.0f s after the last rounds; cancelling", learner, waited
|
|
418
|
+
)
|
|
419
|
+
case LeaseReleased(learner):
|
|
420
|
+
log.info("%s: lease released", learner)
|
|
421
|
+
case ReleaseFailed(learner, error):
|
|
422
|
+
log.error("%s: releasing the lease failed", learner, exc_info=error)
|
|
423
|
+
case Recovered(learner, round):
|
|
424
|
+
log.info("%s: recovered: next round %d", learner, round)
|
|
425
|
+
case RecoveryFailed(learner, error, retry_in):
|
|
426
|
+
log.error("%s: recovery failed; retrying in %.1f s", learner, retry_in, exc_info=error)
|
|
427
|
+
case RoundClaimed(learner, round):
|
|
428
|
+
log.info("%s: claimed round %d", learner, round)
|
|
429
|
+
case FollowingRound(learner, round, claimants, timeout):
|
|
430
|
+
log.info(
|
|
431
|
+
"%s: following round %d claimed by %s; will take over if no record lands in "
|
|
432
|
+
"%.0f s or the claimant's lease ends",
|
|
433
|
+
learner,
|
|
434
|
+
round,
|
|
435
|
+
", ".join(claimants),
|
|
436
|
+
timeout,
|
|
437
|
+
)
|
|
438
|
+
case TookOverRound(learner, round, claimants, cause, timeout):
|
|
439
|
+
why = (
|
|
440
|
+
f"{claimants[-1]}'s lease is gone"
|
|
441
|
+
if cause == "lease_gone"
|
|
442
|
+
else f"no record after {timeout:.0f} s"
|
|
443
|
+
)
|
|
444
|
+
log.warning("%s: taking over round %d: %s", learner, round, why)
|
|
445
|
+
case RoundClosed(learner, draft):
|
|
446
|
+
log.info(
|
|
447
|
+
"%s: round %d closed: fragment %d, participants %s, rejected %s, quorum %.1f s, "
|
|
448
|
+
"grace %.1f s%s",
|
|
449
|
+
learner,
|
|
450
|
+
draft.round,
|
|
451
|
+
draft.fragment,
|
|
452
|
+
[p.learner for p in draft.participants],
|
|
453
|
+
[f"{rejected} ({why})" for rejected, why in draft.rejected],
|
|
454
|
+
draft.quorum_seconds,
|
|
455
|
+
draft.grace_seconds,
|
|
456
|
+
"" if draft.cosine is None else f", cosine {draft.cosine:.3f}",
|
|
457
|
+
)
|
|
458
|
+
case RoundCommitted(learner, record, seconds):
|
|
459
|
+
log.info(
|
|
460
|
+
"%s: round %d committed: version %d, %d bytes, %.1f s",
|
|
461
|
+
learner,
|
|
462
|
+
record.round,
|
|
463
|
+
record.version,
|
|
464
|
+
record.theta.size
|
|
465
|
+
+ record.outer.size
|
|
466
|
+
+ (0 if record.update is None else record.update.payload.size),
|
|
467
|
+
seconds,
|
|
468
|
+
)
|
|
469
|
+
case RoundSuperseded(learner, record):
|
|
470
|
+
log.info("%s: round %d was committed by another node", learner, record.round)
|
|
471
|
+
case RoundFailed(learner, round, error, retry_in):
|
|
472
|
+
log.error(
|
|
473
|
+
"%s: round %d failed; retrying in %.1f s", learner, round, retry_in, exc_info=error
|
|
474
|
+
)
|
|
475
|
+
case SyncerStopped(learner, error):
|
|
476
|
+
log.error("%s: the syncer stopped; the next step() raises", learner, exc_info=error)
|
|
477
|
+
case _:
|
|
478
|
+
assert_never(event)
|