flashruntime 0.4.1__tar.gz → 0.4.3__tar.gz

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 (158) hide show
  1. {flashruntime-0.4.1/flashruntime.egg-info → flashruntime-0.4.3}/PKG-INFO +1 -1
  2. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/fedavg_driver.py +63 -8
  3. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/fedavg_weights.py +148 -3
  4. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/protocol/v1alpha1.py +84 -1
  5. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recipes/command.py +15 -6
  6. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/scheduler/__init__.py +186 -7
  7. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/modea.py +55 -18
  8. {flashruntime-0.4.1 → flashruntime-0.4.3/flashruntime.egg-info}/PKG-INFO +1 -1
  9. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime.egg-info/SOURCES.txt +6 -0
  10. {flashruntime-0.4.1 → flashruntime-0.4.3}/pyproject.toml +26 -1
  11. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_fedavg_driver.py +125 -0
  12. flashruntime-0.4.3/tests/test_fedavg_weights.py +475 -0
  13. flashruntime-0.4.3/tests/test_placement_exclusion.py +296 -0
  14. flashruntime-0.4.3/tests/test_placement_pool.py +170 -0
  15. flashruntime-0.4.3/tests/test_placement_trusted_argv.py +142 -0
  16. flashruntime-0.4.3/tests/test_protocol_evidence.py +279 -0
  17. flashruntime-0.4.3/tests/test_protocol_pools.py +57 -0
  18. flashruntime-0.4.3/tests/test_recipe_pool.py +50 -0
  19. flashruntime-0.4.1/tests/test_fedavg_weights.py +0 -227
  20. {flashruntime-0.4.1 → flashruntime-0.4.3}/LICENSE +0 -0
  21. {flashruntime-0.4.1 → flashruntime-0.4.3}/README.md +0 -0
  22. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/__init__.py +0 -0
  23. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/fedavg_worker.py +0 -0
  24. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/kmeans_driver.py +0 -0
  25. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/kmeans_shard.py +0 -0
  26. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/sgd_trainer.py +0 -0
  27. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/sharded_kmeans.py +0 -0
  28. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/sklearn_trial.py +0 -0
  29. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/__init__.py +0 -0
  30. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/artifacts/__init__.py +0 -0
  31. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/artifacts/store.py +0 -0
  32. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/backends/__init__.py +0 -0
  33. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/backends/base.py +0 -0
  34. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/backends/kuberay.py +0 -0
  35. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/checkpoint/__init__.py +0 -0
  36. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/checkpoint/catalog.py +0 -0
  37. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/checkpoint/local.py +0 -0
  38. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/checkpoint/store.py +0 -0
  39. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/integrations/__init__.py +0 -0
  40. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/integrations/huggingface.py +0 -0
  41. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/integrations/pytorch.py +0 -0
  42. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/integrations/sklearn.py +0 -0
  43. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/launchers/__init__.py +0 -0
  44. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/launchers/local.py +0 -0
  45. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/leases/__init__.py +0 -0
  46. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/leases/manager.py +0 -0
  47. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/leases/sqlite_store.py +0 -0
  48. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/leases/store.py +0 -0
  49. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/monitor/__init__.py +0 -0
  50. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/monitor/sampler.py +0 -0
  51. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/__init__.py +0 -0
  52. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/candidates.py +0 -0
  53. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/catalog.py +0 -0
  54. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/comm.py +0 -0
  55. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/explain.py +0 -0
  56. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/memory.py +0 -0
  57. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/resolve.py +0 -0
  58. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/selector.py +0 -0
  59. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/timecost.py +0 -0
  60. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/profiling/__init__.py +0 -0
  61. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/protocol/__init__.py +0 -0
  62. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/protocol/plan_v1alpha1.py +0 -0
  63. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/providers/__init__.py +0 -0
  64. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/py.typed +0 -0
  65. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recipes/__init__.py +0 -0
  66. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recovery/__init__.py +0 -0
  67. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recovery/policy.py +0 -0
  68. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recovery/signals.py +0 -0
  69. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recovery/taxonomy.py +0 -0
  70. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/sdk.py +0 -0
  71. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/__init__.py +0 -0
  72. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/app.py +0 -0
  73. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/auth.py +0 -0
  74. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/checkpoints.py +0 -0
  75. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/cli.py +0 -0
  76. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/dashboard.py +0 -0
  77. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/ledger.py +0 -0
  78. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/strategies/__init__.py +0 -0
  79. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/strategies/command.py +0 -0
  80. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/torch/__init__.py +0 -0
  81. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/__init__.py +0 -0
  82. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/benchmarks.html +0 -0
  83. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
  84. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/get-started.html +0 -0
  85. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
  86. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
  87. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
  88. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
  89. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
  90. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/index.html +0 -0
  91. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/cli.html +0 -0
  92. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
  93. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
  94. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
  95. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/search-index.json +0 -0
  96. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
  97. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
  98. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
  99. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/flowmap.py +0 -0
  100. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/page.py +0 -0
  101. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/server.py +0 -0
  102. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/state.py +0 -0
  103. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/workloads/__init__.py +0 -0
  104. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/workloads/command.py +0 -0
  105. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime.egg-info/dependency_links.txt +0 -0
  106. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime.egg-info/entry_points.txt +0 -0
  107. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime.egg-info/requires.txt +0 -0
  108. {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime.egg-info/top_level.txt +0 -0
  109. {flashruntime-0.4.1 → flashruntime-0.4.3}/setup.cfg +0 -0
  110. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_auto_recovery.py +0 -0
  111. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_benchmarks.py +0 -0
  112. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_checkpoint.py +0 -0
  113. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_checkpoint_local.py +0 -0
  114. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_cli_submit.py +0 -0
  115. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_documentation.py +0 -0
  116. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_examples_e2e.py +0 -0
  117. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_fedavg_convergence.py +0 -0
  118. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_fedavg_worker.py +0 -0
  119. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_gpu_e2e.py +0 -0
  120. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_integrations.py +0 -0
  121. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_interfaces.py +0 -0
  122. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_kmeans_leases.py +0 -0
  123. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_kmeans_workload.py +0 -0
  124. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_kuberay_backend.py +0 -0
  125. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_launcher_local.py +0 -0
  126. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_leases.py +0 -0
  127. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_leases_scope.py +0 -0
  128. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_leases_sqlite.py +0 -0
  129. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_monitor_sampler.py +0 -0
  130. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_placement_gpu.py +0 -0
  131. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_placement_local_data.py +0 -0
  132. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_planner.py +0 -0
  133. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_protocol_gpu.py +0 -0
  134. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_protocol_local_datasets.py +0 -0
  135. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_protocol_v1alpha1.py +0 -0
  136. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_recipe_gpu.py +0 -0
  137. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_recipe_local_inputs.py +0 -0
  138. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_recipes_command.py +0 -0
  139. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_recovery.py +0 -0
  140. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_run_json.py +0 -0
  141. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_scheduler_isolation.py +0 -0
  142. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_sdk_submit.py +0 -0
  143. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_auth.py +0 -0
  144. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_auth_startup.py +0 -0
  145. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_checkpoints.py +0 -0
  146. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_command_recipe.py +0 -0
  147. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_delegation.py +0 -0
  148. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_fedavg.py +0 -0
  149. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_modea.py +0 -0
  150. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_write_scope.py +0 -0
  151. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_sgd_trainer.py +0 -0
  152. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_strategy_command.py +0 -0
  153. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_torch_helper.py +0 -0
  154. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_viewer_flowmap.py +0 -0
  155. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_viewer_page.py +0 -0
  156. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_viewer_server.py +0 -0
  157. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_viewer_state.py +0 -0
  158. {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_workloads_command.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashruntime
3
- Version: 0.4.1
3
+ Version: 0.4.3
4
4
  Summary: Open fault-tolerant distributed ML runtime: strategy planning, job protocol, leases, checkpointing, and recovery across heterogeneous compute.
5
5
  Author: Zolli Labs
6
6
  License-Expression: Apache-2.0
@@ -29,7 +29,7 @@ from typing import Any, Callable, Protocol, Sequence, TypedDict
29
29
 
30
30
  from flashml_workloads.fedavg_weights import (
31
31
  apply_delta,
32
- reduce_deltas,
32
+ reduce_deltas_with_report,
33
33
  require_finite,
34
34
  )
35
35
 
@@ -73,6 +73,26 @@ class RoundResult(TypedDict):
73
73
  mean_loss: float
74
74
  job_id: str
75
75
 
76
+ #: The round's contributions whose influence the aggregation capped, as
77
+ #: ``{"task_id", "norm", "cap", "scale"}`` — empty on an honest round,
78
+ #: which is every round unless somebody tried.
79
+ #:
80
+ #: ``task_id``, not a node id, because a task id is what this driver
81
+ #: actually holds. `fedavg_weights` reports positional indices; the round
82
+ #: knows which task each position came from, and a task is what a
83
+ #: coordinator leases. Resolving that to a MACHINE means deciding which
84
+ #: attempts count as accepted work, and that judgement already lives in
85
+ #: exactly one place — the cloud's round recorder, which reads the
86
+ #: coordinator's task view for provenance and joins on this task id.
87
+ #: Making a second copy of it here is how the two disagree.
88
+ #:
89
+ #: Recorded, never enforced. Nothing is quarantined, no credit withheld,
90
+ #: no lease refused: with a fleet this small a false positive costs a
91
+ #: volunteer their machine while a false negative costs one undeserved
92
+ #: credit, and that asymmetry only points one way. The row exists so the
93
+ #: owner can look.
94
+ clipped: list[dict]
95
+
76
96
 
77
97
  class RoundPlan(TypedDict):
78
98
  """What one round is: the job body to submit, and the task ids it will
@@ -122,7 +142,8 @@ class Coordinator(Protocol):
122
142
 
123
143
  def _round_body(round_idx: int, num_shards: int, worker_params: dict,
124
144
  weights_uri: str | None, lease_seconds: float,
125
- image: str, isolation_tier: str, allow_fallback: bool) -> dict:
145
+ image: str, isolation_tier: str, allow_fallback: bool,
146
+ pool: str | None = None) -> dict:
126
147
  params: dict[str, Any] = dict(worker_params)
127
148
  params.update({"round": round_idx, "num_shards": num_shards,
128
149
  "lease_seconds": lease_seconds})
@@ -133,7 +154,7 @@ def _round_body(round_idx: int, num_shards: int, worker_params: dict,
133
154
  raise ValueError(
134
155
  f"image must be 'repository:tag' with a pinned tag, got {image!r}"
135
156
  )
136
- return {
157
+ body = {
137
158
  "apiVersion": "flashml.dev/v1alpha1", "kind": "Job",
138
159
  "metadata": {"name": f"fedavg-r{round_idx:03d}"},
139
160
  "spec": {
@@ -143,6 +164,17 @@ def _round_body(round_idx: int, num_shards: int, worker_params: dict,
143
164
  "workload": {"type": "federated_averaging", "parameters": params},
144
165
  },
145
166
  }
167
+ if pool is not None and pool != "any":
168
+ # Same rule as the four expander stamps in recipes/command.py and
169
+ # service/modea.py: absent stays absent, and "any" is never stamped
170
+ # explicitly — the pool placement gate treats a stamped "any" as an
171
+ # ordinary pool id, which would make the round's tasks unplaceable
172
+ # everywhere rather than placeable anywhere. `None` (the caller made
173
+ # no pool statement) and the literal `"any"` (the caller stated the
174
+ # public queue explicitly) both mean "leave `placement` absent" —
175
+ # the default `PlacementSpec.pool` picks up "any" on its own.
176
+ body["spec"]["placement"] = {"pool": pool}
177
+ return body
146
178
 
147
179
 
148
180
  def _default_task_ids(num_shards: int) -> list[str]:
@@ -413,6 +445,7 @@ def run_fedavg(
413
445
  image: str = DEFAULT_IMAGE,
414
446
  isolation_tier: str = "standard",
415
447
  allow_fallback: bool = False,
448
+ pool: str | None = None,
416
449
  poll_attempts: int = 4,
417
450
  poll_backoff_s: float = 0.5,
418
451
  prior_job_ids: Sequence[tuple[int, str]] | None = None,
@@ -428,6 +461,17 @@ def run_fedavg(
428
461
  the same "two places, each correct in isolation" shape as the task-module
429
462
  allowlist drift that already caused an outage here.
430
463
 
464
+ `pool` is caller-settable for the same reason: the cloud control plane
465
+ owns pool ids, not this driver, so it must be free to say which pool (or
466
+ none) each round belongs to. `None` means the public queue, exactly as
467
+ before this parameter existed — every round of every run before pools
468
+ landed built a `placement`-less body, and that must stay byte-identical.
469
+ Round 0 and every later round go through the same `_round_body` call
470
+ inside this loop, so a pool named for round 0 travels to round 1, 2, …
471
+ without the caller repeating itself — the whole point being that pool
472
+ confinement must survive the round boundary, not just the round the job
473
+ was first submitted for.
474
+
431
475
  `build_round` replaces how a round becomes a job. The default builds the
432
476
  built-in `federated_averaging` body, whose tasks run
433
477
  `flashml_workloads.fedavg_worker`. A caller that wants the *user's own*
@@ -437,9 +481,9 @@ def run_fedavg(
437
481
  resume) is unchanged, because none of it depends on what ran inside the
438
482
  round, only on the task ids it produced and the `metrics.json` /
439
483
  `delta.json` pair each one committed. `worker_params`, `image`,
440
- `isolation_tier`, `allow_fallback` and `lease_seconds` are inputs to the
441
- *default* builder and are ignored when `build_round` is supplied — the
442
- builder already knows all of it.
484
+ `isolation_tier`, `allow_fallback`, `pool` and `lease_seconds` are
485
+ inputs to the *default* builder and are ignored when `build_round` is
486
+ supplied — the builder already knows all of it.
443
487
 
444
488
  `initial_weights` may be `{}`, and that is not the same as "start from
445
489
  zeros": it means the driver holds no weights yet, so round 0's reduced
@@ -477,7 +521,7 @@ def run_fedavg(
477
521
  plan: RoundPlan = {
478
522
  "body": _round_body(r, num_shards, worker_params, weights_uri,
479
523
  lease_seconds, image, isolation_tier,
480
- allow_fallback),
524
+ allow_fallback, pool),
481
525
  "task_ids": _default_task_ids(num_shards),
482
526
  }
483
527
  else:
@@ -535,7 +579,17 @@ def run_fedavg(
535
579
  # download. Anything committing from here on is discarded by
536
580
  # construction: we never re-read this job after aggregating.
537
581
  collected = _fetch(coord, keys)
538
- reduced = reduce_deltas([(d, n) for d, n, _ in collected])
582
+ reduced, clip_events = reduce_deltas_with_report(
583
+ [(d, n) for d, n, _ in collected])
584
+ # `_fetch` preserves `keys`' order one-for-one, and every key in
585
+ # `keys` came out of the expected-key map, so a clip event's
586
+ # positional index resolves back to the exact task that sent it.
587
+ # Positional, NOT a shard number: a round aggregates on a quorum, so
588
+ # with shard-001 absent the third committer sits at position 2, and
589
+ # re-deriving `shard-{index:03d}` would name an honest volunteer.
590
+ by_key = _expected_metrics_keys(job_id, task_ids)
591
+ clipped = [{"task_id": by_key[keys[e.index]], "norm": e.norm,
592
+ "cap": e.cap, "scale": e.scale} for e in clip_events]
539
593
  # No weights yet (`initial_weights={}` and nothing aggregated): the
540
594
  # round's workers were handed nothing, so what they reported as
541
595
  # "the change from what you were given" is the weights themselves.
@@ -561,6 +615,7 @@ def run_fedavg(
561
615
  "participants": len(collected),
562
616
  "mean_loss": sum(loss * n for _, n, loss in collected) / total_n,
563
617
  "job_id": job_id,
618
+ "clipped": clipped,
564
619
  }
565
620
  history.append(result)
566
621
  if on_round is not None:
@@ -15,18 +15,48 @@ container.
15
15
  from __future__ import annotations
16
16
 
17
17
  import math
18
+ from typing import NamedTuple
18
19
 
19
20
  __all__ = [
21
+ "CLIP_FACTOR",
22
+ "ClipEvent",
20
23
  "NonFiniteWeights",
21
24
  "WeightShapeMismatch",
22
25
  "apply_delta",
23
26
  "decode",
24
27
  "encode",
25
28
  "reduce_deltas",
29
+ "reduce_deltas_with_report",
26
30
  "require_finite",
27
31
  "subtract",
28
32
  ]
29
33
 
34
+ #: How many times the round's MEDIAN contribution norm a single contribution
35
+ #: may be before it is scaled back to that bound.
36
+ #:
37
+ #: 3.0, and the number is load-bearing. The governing property of the cap is
38
+ #: that an honest round is bit-identical to a round with no cap at all:
39
+ #: honest per-shard variation sits well inside 3x the median, so nothing
40
+ #: fires and the arithmetic below is untouched. A factor of 1.0 would clip
41
+ #: roughly half of every honest round and silently alter results that are
42
+ #: correct today — a behaviour change wearing a safety net's clothes.
43
+ CLIP_FACTOR: float = 3.0
44
+
45
+
46
+ class ClipEvent(NamedTuple):
47
+ """One contribution that exceeded the round's cap, and by how much.
48
+
49
+ `index` is POSITIONAL into the `contributions` list the caller passed,
50
+ not a node id: this module is pure stdlib and knows nothing about
51
+ machines. Attribution is the driver's job — it holds the per-task
52
+ provenance and maps an index back to whoever sent it.
53
+ """
54
+
55
+ index: int
56
+ norm: float
57
+ cap: float
58
+ scale: float
59
+
30
60
 
31
61
  class WeightShapeMismatch(ValueError):
32
62
  """Two weight blobs do not describe the same parameter set.
@@ -138,13 +168,102 @@ def apply_delta(base: dict, delta: dict, scale: float = 1.0) -> dict:
138
168
  }, "apply_delta")
139
169
 
140
170
 
141
- def reduce_deltas(contributions: list[tuple[dict, int]]) -> dict:
171
+ def _l2_norm(blob: dict) -> float:
172
+ """L2 norm of a delta, flattened across every parameter.
173
+
174
+ Only ever called on a blob `require_finite` has already accepted: the
175
+ multiplication below is a `TypeError` on the `None` a volunteer can put
176
+ in `data`, and a NaN anywhere would make the norm NaN, `norm > cap`
177
+ False, and the contribution sail through unscaled.
178
+ """
179
+ return math.sqrt(sum(v * v for p in blob.values() for v in p["data"]))
180
+
181
+
182
+ def _median(values: list[float]) -> float:
183
+ """Median, with the even-length case spelled out: the mean of the two
184
+ middles.
185
+
186
+ Which is precisely why the cap is weak at two contributions — the
187
+ median of two values is their mean, and an attacker moves a mean
188
+ directly. Robust statistics need a majority to be honest, and with
189
+ `min_participants = 2` there is no majority to have. Documented, not
190
+ papered over: this does not fail closed at that quorum and must not be
191
+ described as protection there.
192
+ """
193
+ ordered = sorted(values)
194
+ mid = len(ordered) // 2
195
+ if len(ordered) % 2:
196
+ return ordered[mid]
197
+ return (ordered[mid - 1] + ordered[mid]) / 2
198
+
199
+
200
+ def _scale_blob(blob: dict, scale: float) -> dict:
201
+ return {name: {"shape": list(p["shape"]),
202
+ "data": [scale * v for v in p["data"]]}
203
+ for name, p in blob.items()}
204
+
205
+
206
+ def reduce_deltas(contributions: list[tuple[dict, int]],
207
+ *, clip_factor: float = CLIP_FACTOR) -> dict:
142
208
  """Sample-weighted mean of per-worker deltas (FedAvg).
143
209
 
144
210
  Weighting by sample count, not by worker, is what keeps the result
145
211
  equal to centralized training on the union of the shards when the
146
212
  shards are unequal — which they always are once machines differ.
213
+
214
+ A thin wrapper over `reduce_deltas_with_report`, kept because this name
215
+ has one production caller and 30+ tests pinning it. The clip report is
216
+ additive; callers that want it ask for it by name.
147
217
  """
218
+ return reduce_deltas_with_report(contributions, clip_factor=clip_factor)[0]
219
+
220
+
221
+ def reduce_deltas_with_report(
222
+ contributions: list[tuple[dict, int]],
223
+ *,
224
+ clip_factor: float = CLIP_FACTOR,
225
+ ) -> tuple[dict, list[ClipEvent]]:
226
+ """`reduce_deltas`, plus the list of contributions the cap bound.
227
+
228
+ Everything MALFORMED is rejected below — non-positive, non-finite and
229
+ non-integer sample counts, mismatched shapes, NaN/Inf weights. What none
230
+ of those guards catch is a contribution that is perfectly well-formed
231
+ and adversarial: `delta = 1e6`, `n = 500`. Every check passes and the
232
+ sample-weighted mean moves the model by whatever the sender chose.
233
+
234
+ So, after validation and before the mean, each contribution's L2 norm is
235
+ compared against `C = median(norms) * clip_factor` and anything above it
236
+ is scaled to `C`. Median-anchored rather than a fixed constant because
237
+ the right magnitude depends on the model, the learning rate and the
238
+ round number, none of which this module knows — and because with a
239
+ majority of honest contributors the median is an honest value, which an
240
+ attacker-chosen mean is not.
241
+
242
+ Bounds MAGNITUDE, not direction. A small, consistently-biased delta
243
+ every round is unaffected, a node returning zeros still earns credit,
244
+ and a colluding majority defeats it by construction. It is a cap on how
245
+ far one contributor can move the model, not result verification.
246
+
247
+ Nothing is enforced here beyond the scaling: the events are returned so
248
+ the caller can record them. Revocation is a human decision.
249
+ """
250
+ # First, because it is the function's own configuration rather than
251
+ # untrusted input, and because the failure mode of getting it wrong is
252
+ # the worst one available: a non-positive or non-finite factor would
253
+ # disable the cap silently, the round would still reduce, and it would
254
+ # still report an empty clip list that an operator reads as "nobody
255
+ # tried". `clip_factor=0` would additionally zero every contribution.
256
+ try:
257
+ usable = math.isfinite(clip_factor) and clip_factor > 0
258
+ except TypeError: # not a number at all
259
+ usable = False
260
+ if not usable:
261
+ raise ValueError(
262
+ f"reduce_deltas: clip_factor must be a finite number > 0, got "
263
+ f"{clip_factor!r}; a non-positive or non-finite value would "
264
+ "silently disable the influence cap rather than widening it"
265
+ )
266
+
148
267
  if not contributions:
149
268
  raise ValueError("reduce_deltas: no contributions")
150
269
  total = sum(n for _, n in contributions)
@@ -212,12 +331,38 @@ def reduce_deltas(contributions: list[tuple[dict, int]]) -> dict:
212
331
  _require_same_params(first, blob)
213
332
  require_finite(blob, f"reduce_deltas: contribution {i}")
214
333
 
334
+ # -- bounded influence, and ONLY here: after every guard above, because
335
+ # a malformed contribution must raise its own error rather than be
336
+ # quietly scaled into something plausible. Clipping caps a delta's
337
+ # magnitude and does nothing at all about a negative sample weight.
338
+ #
339
+ # `reduced` deliberately reuses the caller's own blob objects for every
340
+ # contribution that is not clipped, so an honest round accumulates the
341
+ # exact same float objects in the exact same order as it did before this
342
+ # existed. Byte-identical is the governing property; rebuilding every
343
+ # blob "harmlessly" would be the easiest way to lose it.
344
+ # Computed once, not once per use: deltas are megabytes, and this walks
345
+ # every weight in every contribution.
346
+ norms = [_l2_norm(blob) for blob, _ in contributions]
347
+ cap = _median(norms) * clip_factor
348
+ clipped: list[tuple[dict, int]] = list(contributions)
349
+ events: list[ClipEvent] = []
350
+ for i, ((blob, n), norm) in enumerate(zip(contributions, norms)):
351
+ # Strict `>` against a cap that is never negative — so a zero-norm
352
+ # contribution (a converged shard, or a lazy node returning zeros)
353
+ # is never the one being scaled, and `cap / norm` never divides by
354
+ # zero. An all-zero round puts the cap at 0.0 too and clips nothing.
355
+ if norm > cap:
356
+ scale = cap / norm
357
+ clipped[i] = (_scale_blob(blob, scale), n)
358
+ events.append(ClipEvent(index=i, norm=norm, cap=cap, scale=scale))
359
+
215
360
  out: dict = {}
216
361
  for name in first:
217
362
  acc = [0.0] * len(first[name]["data"])
218
- for blob, n in contributions:
363
+ for blob, n in clipped:
219
364
  w = n / total
220
365
  for i, v in enumerate(blob[name]["data"]):
221
366
  acc[i] += w * v
222
367
  out[name] = {"shape": list(first[name]["shape"]), "data": acc}
223
- return out
368
+ return out, events
@@ -96,7 +96,13 @@ class ResourcesSpec(BaseModel):
96
96
 
97
97
 
98
98
  class PlacementSpec(BaseModel):
99
- pool: Literal["any", "local", "edge", "standard-cloud", "secure-cloud"] = "any"
99
+ #: "any" (the default, and every pre-pools job) or a team-pool id minted
100
+ #: by the control plane. Until 0.4.3 this was a closed Literal of
101
+ #: infrastructure pool names that nothing ever read; widened when it
102
+ #: gained its first reader. Non-"any" makes every task of the job carry
103
+ #: payload["pool"] (recipes stamp it) and place only on nodes listing
104
+ #: that id in capabilities.pools.
105
+ pool: str = "any"
100
106
  architectures: list[Literal["amd64", "arm64"]] = Field(default_factory=lambda: ["amd64"])
101
107
 
102
108
 
@@ -309,6 +315,15 @@ class NodeCapabilities(BaseModel):
309
315
  #: this runtime cannot parse — never a guess. Placement reads the LENGTH
310
316
  #: of this list and nothing else in v1.
311
317
  gpus: list[GpuInfo] = Field(default_factory=list)
318
+ #: Team pools this node serves, as pool ids minted by the cloud control
319
+ #: plane. Stamped SERVER-SIDE by the cloud API's agent proxy from the
320
+ #: machine owner's memberships — an agent's self-reported value is
321
+ #: overwritten there, so the field is only as trustworthy as the
322
+ #: operator fronting registration. Empty means "serves no pool", and the
323
+ #: seventh placement gate refuses pool-scoped work (fail closed). NOT
324
+ #: the same thing as NodeRegistration.pool (singular), a deployment
325
+ #: profile label that predates teams; the two never interact.
326
+ pools: list[str] = Field(default_factory=list)
312
327
  os: str = ""
313
328
  architecture: str = ""
314
329
 
@@ -325,6 +340,14 @@ class NodeRegistration(BaseModel):
325
340
  #: every already-deployed agent is excluded from argv work until it is
326
341
  #: upgraded and explicitly opted in (security fields fail closed).
327
342
  argv_capable: bool = False
343
+ #: The operator explicitly opted this host into executing POOL-SCOPED
344
+ #: argv payloads without a container (`flashnode work --runner trusted`).
345
+ #: Defaults False so every existing agent is excluded until its owner
346
+ #: opts in (security fields fail closed). Distinct from argv_capable,
347
+ #: which asserts the CONTAINERISED argv contract — this asserts the
348
+ #: opposite: no sandbox, trusted-pool work only. The scheduler requires
349
+ #: pool + allowFallback + this flag together; any one alone places nothing.
350
+ unsandboxed_argv_capable: bool = False
328
351
  #: This node can run "module" (python -m <allowlisted module>) tasks.
329
352
  #: Defaults True — unlike argv_capable this is an AVAILABILITY gate, not
330
353
  #: a safety one: a module task placed on an incapable node just wastes
@@ -354,6 +377,12 @@ class NodeHeartbeat(BaseModel):
354
377
  node_id: str
355
378
  timestamp: datetime = Field(default_factory=utcnow)
356
379
  status: Literal["online", "draining", "terminating"] = "online"
380
+ #: Optional pool-membership refresh, stamped by the cloud API on the
381
+ #: heartbeat proxy so joining or leaving a pool reaches placement
382
+ #: without an agent restart. None means "no statement" — the
383
+ #: coordinator keeps whatever registration said. A list (even empty)
384
+ #: replaces `capabilities.pools` wholesale. Agents never set this.
385
+ pools: list[str] | None = None
357
386
 
358
387
 
359
388
  class NodeStatusView(BaseModel):
@@ -427,6 +456,60 @@ class TaskAttempt(BaseModel):
427
456
  accepted: bool = False
428
457
 
429
458
 
459
+ class ExecutionEvidence(BaseModel):
460
+ """What the agent says about the run it is committing. **Evidence, never
461
+ proof** — the agent is the untrusted party, so every field here is
462
+ something a liar could fabricate. Its value is that a liar must now keep
463
+ two stories straight: these numbers have to stay consistent with the
464
+ elapsed time the *coordinator* measured between claim and commit, which
465
+ the agent does not control.
466
+
467
+ Optional on `CompleteRequest`, and it must stay optional — see the note
468
+ there.
469
+
470
+ NONE MEANS "NOT MEASURED". ZERO MEANS "MEASURED, AND IT WAS ZERO". These
471
+ are different facts and must never collapse onto one value: 0% GPU on a
472
+ task that asked for a GPU is the strongest single signal this block
473
+ carries, and a host with no sampler at all is no signal whatsoever. An
474
+ agent that cannot read a value sends `None`; a fabricated 0.0 turns "we
475
+ could not look" into "this node did nothing" and gets an honest
476
+ volunteer flagged. `image_digest` uses `""` for the same purpose, matching
477
+ `GpuInfo`'s string fields.
478
+
479
+ DELIBERATELY UNCONSTRAINED. No `ge=0`, no upper bound, no format check on
480
+ the digest. A validator here would let an agent's own self-report 422 the
481
+ agent's own work — inverting a design in which nothing is ever enforced
482
+ and everything is at most flagged. An implausible reading is worth more
483
+ recorded than refused, and refusing it mostly teaches a liar which values
484
+ pass.
485
+
486
+ Fields:
487
+ - `wall_seconds` — the agent's own clock around the run, excluding input
488
+ download and output upload. Cross-checked against coordinator-observed
489
+ elapsed, never trusted alone.
490
+ - `cpu_percent_mean` / `gpu_util_percent_mean` — mean utilisation over
491
+ the run, sampled by the agent. HOST-WIDE, not task-scoped: a volunteer
492
+ machine has other things running on it, so a high reading is weak
493
+ evidence and a *low* one is the interesting direction.
494
+ - `image_digest` — which image bytes actually executed, as the host
495
+ resolved them. `""` for a tier that ran no container at all.
496
+ - `exit_code` — the process/container exit status. On the reference agent
497
+ this is 0 whenever it is present at all, because a non-zero exit fails
498
+ the attempt and never reaches the commit path; it is carried so a
499
+ runner that commits partial results stays expressible, and so absence
500
+ still distinguishes an agent that measured from one that did not.
501
+
502
+ No `schema_version`: like `GpuInfo` and `CheckpointPart`, this is a
503
+ nested element of a wire message, not a wire message of its own.
504
+ """
505
+
506
+ wall_seconds: float | None = None
507
+ cpu_percent_mean: float | None = None
508
+ gpu_util_percent_mean: float | None = None
509
+ image_digest: str = ""
510
+ exit_code: int | None = None
511
+
512
+
430
513
  # ---------------------------------------------------------------------------
431
514
  # Checkpoint manifests (additive, July 2026)
432
515
  #
@@ -82,13 +82,16 @@ class CommandRecipe(WorkloadRecipe):
82
82
 
83
83
  def expand(self, job_id: str, spec: JobSpec) -> list[TaskSpec]:
84
84
  isolation_spec = spec.spec.isolation
85
- if isolation_spec.allowFallback:
86
- # allowFallback waives the sandbox capability requirement at
87
- # placement time. Honouring it for argv would let a submitter
88
- # place arbitrary code on an unsandboxed node.
85
+ pool = spec.spec.placement.pool
86
+ if isolation_spec.allowFallback and pool == "any":
87
+ # The waiver is only acceptable INSIDE a pool: members chose to
88
+ # trust each other, and the seventh gate confines the task to
89
+ # them. Without a pool it would let a submitter place arbitrary
90
+ # code on any opted-in host — the exact thing the original
91
+ # unconditional refusal existed to prevent.
89
92
  raise ValueError(
90
- "command jobs may not set isolation.allowFallback "
91
- "argv execution is container-only"
93
+ "command jobs may not set isolation.allowFallback without "
94
+ "placement.pool — unsandboxed argv is confined to team pools"
92
95
  )
93
96
  if isolation_spec.tier != "sandboxed":
94
97
  # Coordinator-side opt-in only: the operator running the pool
@@ -180,6 +183,12 @@ class CommandRecipe(WorkloadRecipe):
180
183
  # exercising the key-missing branch, as unpack_inputs and
181
184
  # local_inputs do.
182
185
  payload["gpus"] = int(gpus)
186
+ if pool != "any":
187
+ # Same hop, same warning as local_inputs/gpus above: dropping
188
+ # this does NOT fail closed — the seventh gate would see a
189
+ # task requiring nothing and place it anywhere, carrying the
190
+ # waiver with it. Absent stays absent for "any".
191
+ payload["pool"] = pool
183
192
  tasks.append(
184
193
  TaskSpec(
185
194
  task_id=task_id,