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/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)