mantatech-sdk 0.5b0__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.
Files changed (63) hide show
  1. examples/__init__.py +0 -0
  2. examples/mnist_cnn/README.md +107 -0
  3. examples/mnist_cnn/__init__.py +0 -0
  4. examples/mnist_cnn/_bootstrap.py +282 -0
  5. examples/mnist_cnn/driver.py +98 -0
  6. examples/mnist_cnn/notebook.py +231 -0
  7. examples/mnist_cnn/swarm.py +28 -0
  8. examples/mnist_cnn/worker.py +103 -0
  9. manta/__init__.light.py +22 -0
  10. manta/__init__.py +83 -0
  11. manta/__main__.py +21 -0
  12. manta/apis/__init__.py +7 -0
  13. manta/apis/async_user_api.py +6576 -0
  14. manta/apis/graph.py +498 -0
  15. manta/apis/module.py +362 -0
  16. manta/apis/results.py +251 -0
  17. manta/apis/swarm.py +207 -0
  18. manta/apis/user_api.py +1034 -0
  19. manta/cli/__init__.py +1 -0
  20. manta/cli/commands/__init__.py +1 -0
  21. manta/cli/commands/base_handler.py +229 -0
  22. manta/cli/commands/doc.py +192 -0
  23. manta/cli/commands/install.py +346 -0
  24. manta/cli/commands/sdk.py +9 -0
  25. manta/cli/commands/sdk_cluster.py +211 -0
  26. manta/cli/commands/sdk_config.py +347 -0
  27. manta/cli/commands/sdk_globals.py +280 -0
  28. manta/cli/commands/sdk_logs.py +174 -0
  29. manta/cli/commands/sdk_main.py +167 -0
  30. manta/cli/commands/sdk_module.py +516 -0
  31. manta/cli/commands/sdk_nodes.py +168 -0
  32. manta/cli/commands/sdk_original.py +3873 -0
  33. manta/cli/commands/sdk_results.py +265 -0
  34. manta/cli/commands/sdk_swarm.py +454 -0
  35. manta/cli/commands/sdk_user.py +234 -0
  36. manta/cli/commands/status.py +292 -0
  37. manta/cli/component_detector.py +112 -0
  38. manta/cli/config_manager.py +445 -0
  39. manta/cli/main.py +265 -0
  40. manta/cli/utils/__init__.py +27 -0
  41. manta/cli/utils/converters.py +140 -0
  42. manta/clients/_tls.py +41 -0
  43. manta/clients/cluster_management_client.py +491 -0
  44. manta/clients/local_client.py +149 -0
  45. manta/clients/module_management_client.py +222 -0
  46. manta/clients/swarm_management_client.py +590 -0
  47. manta/clients/user_management_client.py +400 -0
  48. manta/clients/world_client.py +195 -0
  49. manta/light/__init__.py +31 -0
  50. manta/light/globals.py +245 -0
  51. manta/light/local.py +407 -0
  52. manta/light/logging_config.py +39 -0
  53. manta/light/path.py +116 -0
  54. manta/light/results.py +236 -0
  55. manta/light/task.py +100 -0
  56. manta/light/utils.py +217 -0
  57. manta/light/world.py +177 -0
  58. mantatech_sdk-0.5b0.dist-info/METADATA +1041 -0
  59. mantatech_sdk-0.5b0.dist-info/RECORD +63 -0
  60. mantatech_sdk-0.5b0.dist-info/WHEEL +5 -0
  61. mantatech_sdk-0.5b0.dist-info/entry_points.txt +2 -0
  62. mantatech_sdk-0.5b0.dist-info/licenses/LICENSE +683 -0
  63. mantatech_sdk-0.5b0.dist-info/top_level.txt +2 -0
examples/__init__.py ADDED
File without changes
@@ -0,0 +1,107 @@
1
+ # MNIST CNN Example
2
+
3
+ Trains a CNN to classify handwritten digits from the MNIST dataset on a Manta node cluster.
4
+
5
+ ## Prerequisites
6
+
7
+ - A Manta account
8
+ - Python 3.10+ and the manta SDK + examples bundle installed: `pip install --pre mantatech-sdk[examples]` (~700 MB CPU / ~2 GB CUDA — most of the weight is `torch` + `torchvision`)
9
+ - **Docker** running locally — the example uses the `ghcr.io/mantatech/manta-light:pytorch` image to execute the worker task
10
+ - Outbound network: gRPC to the manager (`:443`), MQTT-TLS to the broker (`:8883`), HTTPS to `ghcr.io` (image pull, first run only) and the MNIST mirror (dataset download, first run only)
11
+
12
+ > **`--pre` scope:** `pip install --pre` allows pre-release versions across the **whole** dependency tree, not just `mantatech-sdk`. To constrain pre-release resolution to this package only (recommended for reproducibility), use uv: `uv pip install --prerelease=only mantatech-sdk[examples]`.
13
+
14
+ By default the example **bootstraps a cluster + an in-process node for you** — no separate setup needed. If you already have a running cluster with attached nodes (e.g. via [manta-front](https://front.manta-tech.io)'s node wizard or `manta-deploy staging connect`), the example will use that instead.
15
+
16
+ ## Files
17
+
18
+ | File | Where it runs | Purpose |
19
+ |------|--------------|---------|
20
+ | `worker.py` | Inside the Docker container on your node | Defines and trains the CNN |
21
+ | `swarm.py` | Driver (your machine) | Declares the task graph and hyperparameters |
22
+ | `driver.py` | Driver (your machine) | Orchestrates the full run via the Manta API |
23
+ | `notebook.py` | Driver (your machine) | Interactive Marimo notebook — same flow with a live UI |
24
+ | `_bootstrap.py` | Driver (your machine) | Helper: pick an existing RUNNING cluster, or bootstrap a fresh one with an embedded local node (example-internal, not a public SDK API) |
25
+ | `__init__.py` | — | Marks `examples.mnist_cnn` as a Python package so it ships with the wheel |
26
+
27
+ ## Configuration
28
+
29
+ Set these environment variables before running:
30
+
31
+ ```bash
32
+ export MANTA_HOST=api.manta-tech.io # or your self-hosted manager host
33
+ export MANTA_PORT=443 # 443 for cloud, 50051 for local
34
+
35
+ # Either a JWT token (preferred — skips sign-in):
36
+ export MANTA_TOKEN=<your-jwt-token>
37
+
38
+ # Or email + password sign-in:
39
+ export MANTA_EMAIL=you@example.com
40
+ export MANTA_PASSWORD=yourpassword
41
+
42
+ # Optional — cluster selection:
43
+ export MANTA_CLUSTER_ID=<cluster-id> # pin to a specific RUNNING cluster
44
+ export MANTA_BOOTSTRAP=0 # disable the auto-bootstrap fallback
45
+ ```
46
+
47
+ The notebook UI surfaces credentials as fields. `MANTA_TOKEN` takes precedence; if it's set, email/password are ignored.
48
+
49
+ **Cluster resolution order**: a specific `MANTA_CLUSTER_ID` (if set) → the first RUNNING cluster you own with at least one node attached → a freshly bootstrapped cluster with an embedded local node (unless `MANTA_BOOTSTRAP=0`).
50
+
51
+ ## Running
52
+
53
+ The example is bundled inside the SDK install (`mantatech-sdk[examples]`), so it works from any directory once installed.
54
+
55
+ **Script (terminal):**
56
+
57
+ ```bash
58
+ python -m examples.mnist_cnn.driver
59
+ ```
60
+
61
+ The driver signs in, acquires a cluster (existing or freshly bootstrapped), deploys the swarm, polls until training finishes, and prints per-node accuracy and loss. A cluster the example bootstrapped is cleaned up on exit; one you already owned is left alone.
62
+
63
+ Expected output (3 epochs, 5 000 samples, seed 42):
64
+
65
+ ```
66
+ Connecting to api.manta-tech.io:443 ...
67
+ Using cluster <id>
68
+ Swarm deployed: <id>
69
+ [ 10s] status: ACTIVE
70
+ ...
71
+ [ 30s] status: ACTIVE
72
+
73
+ Results:
74
+ iteration=0 node=<node_id> accuracy=0.9646 loss=0.1107
75
+ ```
76
+
77
+ **Interactive notebook (Marimo):**
78
+
79
+ ```bash
80
+ # Marimo loads notebooks by path; resolve the installed location:
81
+ NOTEBOOK=$(python -c "import examples.mnist_cnn; print(examples.mnist_cnn.__path__[0])")/notebook.py
82
+
83
+ marimo run "$NOTEBOOK" # browser UI, read-only
84
+ marimo edit "$NOTEBOOK" # browser UI, editable cells
85
+ ```
86
+
87
+ The notebook provides credential inputs, hyperparameter sliders, a run button, a live status log, and a results table — no terminal required.
88
+
89
+ ## Hyperparameters
90
+
91
+ Edit `swarm.py` to change training behaviour:
92
+
93
+ ```python
94
+ self.set_global("hyperparameters", {
95
+ "epochs": 3,
96
+ "batch_size": 64,
97
+ "lr": 0.001,
98
+ "seed": 42, # controls dataset subset + weight init
99
+ "num_samples": 5000,
100
+ })
101
+ ```
102
+
103
+ The `seed` value makes results reproducible across runs.
104
+
105
+ ## Reproducibility
106
+
107
+ Fixed seeds (`seed=42`, fixed `num_samples`, seeded DataLoader generator) make runs reproducible on the same node hardware. The example is not currently wired into CI; future integration tests can build on these seeds to assert a known accuracy value.
File without changes
@@ -0,0 +1,282 @@
1
+ """Embed a manta-node in the driver process so the example is self-contained.
2
+
3
+ Not a public SDK API — lives next to the example because a fresh Beta user
4
+ has no cluster + no nodes, so the canonical pattern (find a RUNNING cluster
5
+ via stream_clusters) errors fast with no recovery path. This helper creates
6
+ a cluster, embeds a Node in the current event loop, and yields once the
7
+ node has registered with the manager.
8
+
9
+ Requires Docker on the host machine — the embedded node still spawns a
10
+ container per task (manta-light:pytorch image). See the example README.
11
+ """
12
+
13
+ from __future__ import annotations
14
+
15
+ import asyncio
16
+ import json
17
+ import logging
18
+ import shutil
19
+ import socket
20
+ import ssl
21
+ import tempfile
22
+ import time
23
+ from contextlib import asynccontextmanager
24
+ from pathlib import Path
25
+ from typing import AsyncIterator, Tuple
26
+
27
+ from manta.apis import AsyncUserAPI
28
+ from manta_node import Node
29
+ from manta_node.infrastructure.config import NodeConfigManager
30
+
31
+ _logger = logging.getLogger(__name__)
32
+
33
+ _DEFAULT_TIMEOUT_S = 60
34
+ _STOP_TIMEOUT_S = 10
35
+ _TLS_READY_TIMEOUT_S = 120.0
36
+ _TLS_RETRY_S = 5.0
37
+
38
+
39
+ def _wait_for_tls_ready_sync(host: str, port: int, timeout: float) -> None:
40
+ """Block until a TLS handshake to host:port succeeds with the system CA bundle.
41
+
42
+ cert-manager provisions a real TLS cert lazily for fresh per-cluster
43
+ subdomains — there's a window where the ingress serves a self-signed
44
+ fallback. Polling here avoids the Node startup race that would otherwise
45
+ surface as a misleading "JWT_SECRET mismatch" error from
46
+ `node_orchestrator.check_manager_availability`.
47
+ """
48
+ ctx = ssl.create_default_context()
49
+ deadline = time.monotonic() + timeout
50
+ attempt = 0
51
+ last_exc: BaseException = Exception("no attempt yet")
52
+ while True:
53
+ attempt += 1
54
+ remaining = deadline - time.monotonic()
55
+ if remaining <= 0:
56
+ raise TimeoutError(
57
+ f"TLS readiness probe timed out after {timeout:.0f}s waiting for "
58
+ f"{host}:{port} to serve a trusted certificate. Last error: {last_exc!r}"
59
+ )
60
+ sock_timeout = min(10.0, remaining)
61
+ try:
62
+ with socket.create_connection((host, port), timeout=sock_timeout) as raw:
63
+ with ctx.wrap_socket(raw, server_hostname=host):
64
+ return
65
+ except ssl.SSLCertVerificationError as exc:
66
+ last_exc = exc
67
+ except OSError as exc:
68
+ last_exc = exc
69
+ print(f" waiting for TLS readiness: {host}:{port} (attempt {attempt})...")
70
+ time.sleep(min(_TLS_RETRY_S, max(0.0, deadline - time.monotonic())))
71
+
72
+
73
+ def _render_node_toml(
74
+ *, alias: str, manager_host: str, manager_port: int, jwt_token: str
75
+ ) -> str:
76
+ return f"""# Auto-generated by mnist_cnn bootstrap — do not edit.
77
+
78
+ [identity]
79
+ alias = {json.dumps(alias)}
80
+ secured_token = {json.dumps(jwt_token)}
81
+ random_id = false
82
+
83
+ [metadata]
84
+ name = {json.dumps(alias)}
85
+
86
+ [network]
87
+ manager_host = {json.dumps(manager_host)}
88
+ manager_port = {manager_port}
89
+
90
+ [logging]
91
+ level = "INFO"
92
+ log_to_file = true
93
+ log_to_console = false
94
+ """
95
+
96
+
97
+ _DEFAULT_CLUSTER_NAME = "manta-sdk-mnist-cnn-example"
98
+
99
+
100
+ async def _sweep_prior_clusters(api: AsyncUserAPI, name: str) -> int:
101
+ """Stop + delete any existing clusters with this exact name.
102
+
103
+ Idempotency over orphans: the example's create-or-reset model assumes
104
+ any prior cluster with our reserved name is stale (the embedded node
105
+ that was attached to it died with the previous process). Sweep them
106
+ before creating a fresh one to avoid the manager surfacing them as
107
+ healthy-but-actually-dead.
108
+ """
109
+ swept = 0
110
+ async for cluster in api.stream_clusters():
111
+ if cluster.name != name:
112
+ continue
113
+ try:
114
+ await api.stop_cluster(cluster.cluster_id)
115
+ except Exception as exc:
116
+ _logger.debug("stop_cluster(%s) failed during sweep: %s", cluster.cluster_id, exc)
117
+ try:
118
+ await api.delete_cluster(cluster.cluster_id)
119
+ swept += 1
120
+ print(f" swept prior example cluster {cluster.cluster_id}")
121
+ except Exception as exc:
122
+ _logger.warning(
123
+ "delete_cluster(%s) failed during sweep: %s — continuing",
124
+ cluster.cluster_id,
125
+ exc,
126
+ )
127
+ return swept
128
+
129
+
130
+ @asynccontextmanager
131
+ async def bootstrap_local_node(
132
+ api: AsyncUserAPI,
133
+ *,
134
+ cluster_name: str = _DEFAULT_CLUSTER_NAME,
135
+ node_alias: str = "local-node",
136
+ wait_timeout_s: int = _DEFAULT_TIMEOUT_S,
137
+ ) -> AsyncIterator[Tuple[str, str]]:
138
+ """Create a cluster, embed a Node in the current loop, yield (cluster_id, node_id).
139
+
140
+ Idempotent across runs: any prior cluster with this exact name is
141
+ swept before creating a new one (the previous embedded node is dead
142
+ by the time we run again). On exit: stops the node, deletes the
143
+ cluster, removes the temp config.
144
+ """
145
+ work_dir = Path(tempfile.mkdtemp(prefix="manta-mnist-bootstrap-"))
146
+ config_path = work_dir / "node.toml"
147
+ cluster_id: str | None = None
148
+ node: Node | None = None
149
+ node_task: asyncio.Task | None = None
150
+
151
+ try:
152
+ await _sweep_prior_clusters(api, cluster_name)
153
+ cluster_id = await api.create_cluster(name=cluster_name)
154
+ start = await api.start_cluster(cluster_id)
155
+ manager_host = getattr(start, "node_host", None)
156
+ manager_port = getattr(start, "node_port", None)
157
+ jwt_token = getattr(start, "jwt_token", None)
158
+ if not (manager_host and manager_port and jwt_token):
159
+ raise RuntimeError(
160
+ "start_cluster did not return node_host/node_port/jwt_token"
161
+ )
162
+
163
+ config_path.write_text(
164
+ _render_node_toml(
165
+ alias=node_alias,
166
+ manager_host=manager_host,
167
+ manager_port=int(manager_port),
168
+ jwt_token=jwt_token,
169
+ )
170
+ )
171
+ config_path.chmod(0o600)
172
+
173
+ # Wait for cert-manager to provision a trusted TLS cert for the
174
+ # per-cluster watchdog subdomain before starting the node. Without
175
+ # this, Node.check_manager_availability() races the cert issuance
176
+ # and dies with a misleading "JWT_SECRET mismatch" error.
177
+ await asyncio.to_thread(
178
+ _wait_for_tls_ready_sync,
179
+ manager_host,
180
+ int(manager_port),
181
+ _TLS_READY_TIMEOUT_S,
182
+ )
183
+
184
+ mgr = NodeConfigManager(config_path)
185
+ node = Node(mgr)
186
+
187
+ # Node.start() runs forever — drive it as a task, use node registration
188
+ # at the manager as the readiness signal.
189
+ node_task = asyncio.create_task(node.start(), name="manta-node-embedded")
190
+
191
+ deadline = time.monotonic() + wait_timeout_s
192
+ node_id: str | None = None
193
+ while True:
194
+ if node_task.done():
195
+ exc = node_task.exception()
196
+ if exc:
197
+ raise exc
198
+ raise RuntimeError("Node.start() returned before registration")
199
+ node_ids = await api.list_node_ids(cluster_id)
200
+ if node_ids:
201
+ node_id = node_ids[0]
202
+ break
203
+ if time.monotonic() > deadline:
204
+ raise TimeoutError(
205
+ f"Embedded node did not register within {wait_timeout_s}s"
206
+ )
207
+ await asyncio.sleep(1)
208
+
209
+ yield cluster_id, node_id
210
+
211
+ finally:
212
+ if node is not None:
213
+ try:
214
+ await asyncio.wait_for(node.stop(), timeout=_STOP_TIMEOUT_S)
215
+ except (asyncio.TimeoutError, Exception) as exc:
216
+ _logger.warning("node.stop() failed: %s", exc)
217
+ if node_task is not None and not node_task.done():
218
+ node_task.cancel()
219
+ try:
220
+ await node_task
221
+ except (asyncio.CancelledError, Exception):
222
+ pass
223
+ if cluster_id:
224
+ try:
225
+ await api.stop_cluster(cluster_id)
226
+ await api.delete_cluster(cluster_id)
227
+ except Exception as exc:
228
+ _logger.warning("cluster cleanup failed: %s", exc)
229
+ shutil.rmtree(work_dir, ignore_errors=True)
230
+
231
+
232
+ @asynccontextmanager
233
+ async def acquire_cluster(
234
+ api: AsyncUserAPI,
235
+ *,
236
+ requested_cluster_id: str | None = None,
237
+ allow_bootstrap: bool = True,
238
+ ) -> AsyncIterator[str]:
239
+ """Pick a usable cluster.
240
+
241
+ Resolution order:
242
+ 1. If `requested_cluster_id` is set, use that cluster (must be RUNNING
243
+ with at least one node attached). No bootstrap, no cleanup on exit.
244
+ 2. Else, if `allow_bootstrap`, sweep any prior auto-bootstrapped cluster
245
+ and bootstrap a fresh one with an embedded local node. Cleaned up
246
+ on exit.
247
+
248
+ The "auto-pick an existing RUNNING cluster" fallback was deliberately
249
+ removed: it produced confusing behaviour when a previous run's embedded
250
+ node had died but its cluster + stale node entry still existed
251
+ server-side, causing the example to silently deploy onto a dead node.
252
+ Power users with their own running cluster should set MANTA_CLUSTER_ID.
253
+ """
254
+ if requested_cluster_id:
255
+ async for cluster in api.stream_clusters():
256
+ if cluster.cluster_id != requested_cluster_id:
257
+ continue
258
+ if str(cluster.status) != "RUNNING":
259
+ raise RuntimeError(
260
+ f"Cluster {requested_cluster_id} status={cluster.status!s} — must be RUNNING."
261
+ )
262
+ node_ids = await api.list_node_ids(cluster.cluster_id)
263
+ if not node_ids:
264
+ raise RuntimeError(
265
+ f"Cluster {cluster.cluster_id} has 0 nodes attached. "
266
+ "Attach a node before running the example."
267
+ )
268
+ yield cluster.cluster_id
269
+ return
270
+ raise RuntimeError(
271
+ f"No cluster found matching MANTA_CLUSTER_ID={requested_cluster_id}."
272
+ )
273
+
274
+ if not allow_bootstrap:
275
+ raise RuntimeError(
276
+ "MANTA_BOOTSTRAP=0 is set and no MANTA_CLUSTER_ID provided. "
277
+ "Set MANTA_CLUSTER_ID to a RUNNING cluster you've already prepared, "
278
+ "or re-enable bootstrap."
279
+ )
280
+
281
+ async with bootstrap_local_node(api) as (cluster_id, _node_id):
282
+ yield cluster_id
@@ -0,0 +1,98 @@
1
+ import asyncio
2
+ import os
3
+ import sys
4
+
5
+ from manta.apis import AsyncUserAPI
6
+ from manta_common.conversions import ID, bytes_to_dict
7
+
8
+ from examples.mnist_cnn._bootstrap import acquire_cluster
9
+ from examples.mnist_cnn.swarm import MnistCnnSwarm
10
+
11
+ POLL_INTERVAL_S = 10
12
+ TIMEOUT_S = 600
13
+
14
+
15
+ async def main():
16
+ host = os.environ.get("MANTA_HOST", "localhost")
17
+ port = int(os.environ.get("MANTA_PORT", "50051"))
18
+ token = os.environ.get("MANTA_TOKEN")
19
+ email = os.environ.get("MANTA_EMAIL")
20
+ password = os.environ.get("MANTA_PASSWORD")
21
+
22
+ if not token and not (email and password):
23
+ print(
24
+ "Error: set MANTA_TOKEN (preferred) or MANTA_EMAIL + MANTA_PASSWORD."
25
+ )
26
+ sys.exit(1)
27
+
28
+ print(f"Connecting to {host}:{port} ...")
29
+
30
+ requested_cluster_id = os.environ.get("MANTA_CLUSTER_ID")
31
+ allow_bootstrap = os.environ.get("MANTA_BOOTSTRAP", "1") != "0"
32
+
33
+ if token:
34
+ api = AsyncUserAPI(token=token, host=host, port=port)
35
+ else:
36
+ api = await AsyncUserAPI.sign_in(email, password, host=host, port=port)
37
+
38
+ async with api:
39
+ async with acquire_cluster(
40
+ api,
41
+ requested_cluster_id=requested_cluster_id,
42
+ allow_bootstrap=allow_bootstrap,
43
+ ) as cluster_id:
44
+ print(f"Using cluster {cluster_id}")
45
+
46
+ swarm_proto = await api.deploy_swarm(cluster_id, MnistCnnSwarm())
47
+ swarm_id = ID(swarm_proto.swarm_id).xid
48
+ print(f"Swarm deployed: {swarm_id}")
49
+
50
+ # Single-shot training: poll for results to appear, with the swarm
51
+ # status as a backstop in case training fails. We don't wait for
52
+ # the swarm itself to transition to STOPPED — SwarmStatusEnum has
53
+ # no terminal "completed" state for one-off tasks.
54
+ elapsed = 0
55
+ result_obj = None
56
+ while elapsed < TIMEOUT_S:
57
+ await asyncio.sleep(POLL_INTERVAL_S)
58
+ elapsed += POLL_INTERVAL_S
59
+ results = await api.select_results(swarm_id, "metrics")
60
+ result_obj = results.get(swarm_id)
61
+ if result_obj and result_obj.values:
62
+ break
63
+ current = await api.get_swarm(swarm_id)
64
+ status = str(current.status)
65
+ print(f" [{elapsed:>4}s] status: {status}")
66
+ if status == "KILLED":
67
+ print(f"Swarm ended in KILLED state — training failed.")
68
+ return
69
+
70
+ if not (result_obj and result_obj.values):
71
+ print(f"Timed out after {elapsed}s without receiving any results.")
72
+ return
73
+
74
+ print("\nResults:")
75
+ for i, iter_data in enumerate(result_obj.values):
76
+ for node_id, row in zip(result_obj.schema.rows, iter_data):
77
+ column_data = dict(zip(result_obj.schema.columns, row))
78
+ # The "metrics" column holds the serialized worker payload
79
+ # (the dict the worker stored via self.world.results.add).
80
+ payload = column_data.get("metrics")
81
+ if isinstance(payload, (bytes, bytearray)):
82
+ metrics = bytes_to_dict(payload)
83
+ elif isinstance(payload, dict):
84
+ metrics = payload
85
+ else:
86
+ metrics = {}
87
+ acc = metrics.get("accuracy")
88
+ loss = metrics.get("loss")
89
+ acc_s = f"{acc:.4f}" if isinstance(acc, (int, float)) else "?"
90
+ loss_s = f"{loss:.4f}" if isinstance(loss, (int, float)) else "?"
91
+ print(
92
+ f" iteration={i} node={node_id} "
93
+ f"accuracy={acc_s} loss={loss_s}"
94
+ )
95
+
96
+
97
+ if __name__ == "__main__":
98
+ asyncio.run(main())