flashruntime 0.4.2__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 (157) hide show
  1. {flashruntime-0.4.2 → flashruntime-0.4.3}/PKG-INFO +1 -1
  2. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashml_workloads/fedavg_driver.py +30 -6
  3. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/protocol/v1alpha1.py +30 -1
  4. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/recipes/command.py +15 -6
  5. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/scheduler/__init__.py +122 -7
  6. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/service/modea.py +40 -18
  7. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime.egg-info/PKG-INFO +1 -1
  8. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime.egg-info/SOURCES.txt +4 -0
  9. {flashruntime-0.4.2 → flashruntime-0.4.3}/pyproject.toml +13 -1
  10. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_fedavg_driver.py +47 -0
  11. flashruntime-0.4.3/tests/test_placement_pool.py +170 -0
  12. flashruntime-0.4.3/tests/test_placement_trusted_argv.py +142 -0
  13. flashruntime-0.4.3/tests/test_protocol_pools.py +57 -0
  14. flashruntime-0.4.3/tests/test_recipe_pool.py +50 -0
  15. {flashruntime-0.4.2 → flashruntime-0.4.3}/LICENSE +0 -0
  16. {flashruntime-0.4.2 → flashruntime-0.4.3}/README.md +0 -0
  17. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashml_workloads/__init__.py +0 -0
  18. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashml_workloads/fedavg_weights.py +0 -0
  19. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashml_workloads/fedavg_worker.py +0 -0
  20. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashml_workloads/kmeans_driver.py +0 -0
  21. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashml_workloads/kmeans_shard.py +0 -0
  22. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashml_workloads/sgd_trainer.py +0 -0
  23. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashml_workloads/sharded_kmeans.py +0 -0
  24. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashml_workloads/sklearn_trial.py +0 -0
  25. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/__init__.py +0 -0
  26. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/artifacts/__init__.py +0 -0
  27. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/artifacts/store.py +0 -0
  28. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/backends/__init__.py +0 -0
  29. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/backends/base.py +0 -0
  30. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/backends/kuberay.py +0 -0
  31. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/checkpoint/__init__.py +0 -0
  32. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/checkpoint/catalog.py +0 -0
  33. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/checkpoint/local.py +0 -0
  34. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/checkpoint/store.py +0 -0
  35. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/integrations/__init__.py +0 -0
  36. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/integrations/huggingface.py +0 -0
  37. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/integrations/pytorch.py +0 -0
  38. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/integrations/sklearn.py +0 -0
  39. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/launchers/__init__.py +0 -0
  40. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/launchers/local.py +0 -0
  41. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/leases/__init__.py +0 -0
  42. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/leases/manager.py +0 -0
  43. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/leases/sqlite_store.py +0 -0
  44. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/leases/store.py +0 -0
  45. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/monitor/__init__.py +0 -0
  46. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/monitor/sampler.py +0 -0
  47. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/planner/__init__.py +0 -0
  48. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/planner/candidates.py +0 -0
  49. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/planner/catalog.py +0 -0
  50. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/planner/comm.py +0 -0
  51. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/planner/explain.py +0 -0
  52. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/planner/memory.py +0 -0
  53. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/planner/resolve.py +0 -0
  54. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/planner/selector.py +0 -0
  55. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/planner/timecost.py +0 -0
  56. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/profiling/__init__.py +0 -0
  57. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/protocol/__init__.py +0 -0
  58. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/protocol/plan_v1alpha1.py +0 -0
  59. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/providers/__init__.py +0 -0
  60. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/py.typed +0 -0
  61. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/recipes/__init__.py +0 -0
  62. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/recovery/__init__.py +0 -0
  63. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/recovery/policy.py +0 -0
  64. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/recovery/signals.py +0 -0
  65. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/recovery/taxonomy.py +0 -0
  66. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/sdk.py +0 -0
  67. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/service/__init__.py +0 -0
  68. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/service/app.py +0 -0
  69. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/service/auth.py +0 -0
  70. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/service/checkpoints.py +0 -0
  71. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/service/cli.py +0 -0
  72. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/service/dashboard.py +0 -0
  73. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/service/ledger.py +0 -0
  74. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/strategies/__init__.py +0 -0
  75. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/strategies/command.py +0 -0
  76. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/torch/__init__.py +0 -0
  77. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/__init__.py +0 -0
  78. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/benchmarks.html +0 -0
  79. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
  80. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/get-started.html +0 -0
  81. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
  82. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
  83. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
  84. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
  85. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
  86. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/index.html +0 -0
  87. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/cli.html +0 -0
  88. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
  89. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
  90. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
  91. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/search-index.json +0 -0
  92. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
  93. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
  94. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
  95. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/flowmap.py +0 -0
  96. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/page.py +0 -0
  97. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/server.py +0 -0
  98. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/viewer/state.py +0 -0
  99. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/workloads/__init__.py +0 -0
  100. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime/workloads/command.py +0 -0
  101. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime.egg-info/dependency_links.txt +0 -0
  102. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime.egg-info/entry_points.txt +0 -0
  103. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime.egg-info/requires.txt +0 -0
  104. {flashruntime-0.4.2 → flashruntime-0.4.3}/flashruntime.egg-info/top_level.txt +0 -0
  105. {flashruntime-0.4.2 → flashruntime-0.4.3}/setup.cfg +0 -0
  106. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_auto_recovery.py +0 -0
  107. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_benchmarks.py +0 -0
  108. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_checkpoint.py +0 -0
  109. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_checkpoint_local.py +0 -0
  110. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_cli_submit.py +0 -0
  111. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_documentation.py +0 -0
  112. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_examples_e2e.py +0 -0
  113. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_fedavg_convergence.py +0 -0
  114. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_fedavg_weights.py +0 -0
  115. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_fedavg_worker.py +0 -0
  116. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_gpu_e2e.py +0 -0
  117. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_integrations.py +0 -0
  118. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_interfaces.py +0 -0
  119. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_kmeans_leases.py +0 -0
  120. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_kmeans_workload.py +0 -0
  121. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_kuberay_backend.py +0 -0
  122. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_launcher_local.py +0 -0
  123. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_leases.py +0 -0
  124. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_leases_scope.py +0 -0
  125. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_leases_sqlite.py +0 -0
  126. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_monitor_sampler.py +0 -0
  127. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_placement_exclusion.py +0 -0
  128. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_placement_gpu.py +0 -0
  129. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_placement_local_data.py +0 -0
  130. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_planner.py +0 -0
  131. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_protocol_evidence.py +0 -0
  132. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_protocol_gpu.py +0 -0
  133. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_protocol_local_datasets.py +0 -0
  134. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_protocol_v1alpha1.py +0 -0
  135. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_recipe_gpu.py +0 -0
  136. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_recipe_local_inputs.py +0 -0
  137. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_recipes_command.py +0 -0
  138. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_recovery.py +0 -0
  139. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_run_json.py +0 -0
  140. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_scheduler_isolation.py +0 -0
  141. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_sdk_submit.py +0 -0
  142. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_service_auth.py +0 -0
  143. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_service_auth_startup.py +0 -0
  144. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_service_checkpoints.py +0 -0
  145. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_service_command_recipe.py +0 -0
  146. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_service_delegation.py +0 -0
  147. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_service_fedavg.py +0 -0
  148. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_service_modea.py +0 -0
  149. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_service_write_scope.py +0 -0
  150. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_sgd_trainer.py +0 -0
  151. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_strategy_command.py +0 -0
  152. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_torch_helper.py +0 -0
  153. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_viewer_flowmap.py +0 -0
  154. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_viewer_page.py +0 -0
  155. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_viewer_server.py +0 -0
  156. {flashruntime-0.4.2 → flashruntime-0.4.3}/tests/test_viewer_state.py +0 -0
  157. {flashruntime-0.4.2 → 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.2
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
@@ -142,7 +142,8 @@ class Coordinator(Protocol):
142
142
 
143
143
  def _round_body(round_idx: int, num_shards: int, worker_params: dict,
144
144
  weights_uri: str | None, lease_seconds: float,
145
- image: str, isolation_tier: str, allow_fallback: bool) -> dict:
145
+ image: str, isolation_tier: str, allow_fallback: bool,
146
+ pool: str | None = None) -> dict:
146
147
  params: dict[str, Any] = dict(worker_params)
147
148
  params.update({"round": round_idx, "num_shards": num_shards,
148
149
  "lease_seconds": lease_seconds})
@@ -153,7 +154,7 @@ def _round_body(round_idx: int, num_shards: int, worker_params: dict,
153
154
  raise ValueError(
154
155
  f"image must be 'repository:tag' with a pinned tag, got {image!r}"
155
156
  )
156
- return {
157
+ body = {
157
158
  "apiVersion": "flashml.dev/v1alpha1", "kind": "Job",
158
159
  "metadata": {"name": f"fedavg-r{round_idx:03d}"},
159
160
  "spec": {
@@ -163,6 +164,17 @@ def _round_body(round_idx: int, num_shards: int, worker_params: dict,
163
164
  "workload": {"type": "federated_averaging", "parameters": params},
164
165
  },
165
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
166
178
 
167
179
 
168
180
  def _default_task_ids(num_shards: int) -> list[str]:
@@ -433,6 +445,7 @@ def run_fedavg(
433
445
  image: str = DEFAULT_IMAGE,
434
446
  isolation_tier: str = "standard",
435
447
  allow_fallback: bool = False,
448
+ pool: str | None = None,
436
449
  poll_attempts: int = 4,
437
450
  poll_backoff_s: float = 0.5,
438
451
  prior_job_ids: Sequence[tuple[int, str]] | None = None,
@@ -448,6 +461,17 @@ def run_fedavg(
448
461
  the same "two places, each correct in isolation" shape as the task-module
449
462
  allowlist drift that already caused an outage here.
450
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
+
451
475
  `build_round` replaces how a round becomes a job. The default builds the
452
476
  built-in `federated_averaging` body, whose tasks run
453
477
  `flashml_workloads.fedavg_worker`. A caller that wants the *user's own*
@@ -457,9 +481,9 @@ def run_fedavg(
457
481
  resume) is unchanged, because none of it depends on what ran inside the
458
482
  round, only on the task ids it produced and the `metrics.json` /
459
483
  `delta.json` pair each one committed. `worker_params`, `image`,
460
- `isolation_tier`, `allow_fallback` and `lease_seconds` are inputs to the
461
- *default* builder and are ignored when `build_round` is supplied — the
462
- 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.
463
487
 
464
488
  `initial_weights` may be `{}`, and that is not the same as "start from
465
489
  zeros": it means the driver holds no weights yet, so round 0's reduced
@@ -497,7 +521,7 @@ def run_fedavg(
497
521
  plan: RoundPlan = {
498
522
  "body": _round_body(r, num_shards, worker_params, weights_uri,
499
523
  lease_seconds, image, isolation_tier,
500
- allow_fallback),
524
+ allow_fallback, pool),
501
525
  "task_ids": _default_task_ids(num_shards),
502
526
  }
503
527
  else:
@@ -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):
@@ -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,
@@ -113,11 +113,36 @@ class IsolationAwarePlacement(PlacementPolicy):
113
113
 
114
114
  A second, independent gate applies to tasks carrying an `argv` payload
115
115
  (arbitrary user command lines): the claiming node must advertise
116
- `argv_capable is True`. This is checked BEFORE the isolation block's
117
- `allowFallback` waiver below, and the waiver does not apply to it — a
118
- submitter setting `allowFallback: true` waives the sandbox-tier
119
- requirement only, never the argv-runner requirement, or arbitrary argv
120
- could land on a node with no argv runner at all.
116
+ `argv_capable is True` the containerised argv contract, always
117
+ acceptable. This is checked BEFORE the isolation block's `allowFallback`
118
+ waiver below, and the waiver does not apply to it directly — a submitter
119
+ setting `allowFallback: true` waives the sandbox-tier requirement only,
120
+ never the argv-runner requirement on its own, or arbitrary argv could
121
+ land on a node with no argv runner at all.
122
+
123
+ A node lacking `argv_capable` has exactly one alternative: trusted-pool
124
+ placement, for a host whose OPERATOR opted it into running pool argv
125
+ work unsandboxed (`flashnode work --runner trusted`,
126
+ `NodeRegistration.unsandboxed_argv_capable`). Three legs, ALL required,
127
+ each `is`-checked, each fails closed — any one alone places nothing:
128
+
129
+ - The task is pool-scoped: `payload["pool"]` is a non-empty `str`.
130
+ - Its submitter waived the tier: `payload["isolation"]` is a `dict` and
131
+ its `allowFallback` is exactly `True` — the same waiver the isolation
132
+ block reads below, checked again here rather than assumed, because
133
+ this gate must not depend on evaluation order elsewhere.
134
+ - The node opted in: `unsandboxed_argv_capable is True` on the node view
135
+ — a truthy stand-in (`1`, `"true"`) does NOT count, matching every
136
+ other boolean capability in this class.
137
+
138
+ The pool leg here is a guard, not the boundary: the seventh gate below
139
+ independently confines the task to pool members regardless of this one.
140
+ This leg exists so that a waiver which somehow reached placement without
141
+ a pool — `CommandRecipe` now accepts `allowFallback` only when
142
+ `placement.pool` is set, coupling the waiver to a pool at submission
143
+ time, so nothing upstream should ever produce one without a pool — still
144
+ unlocks nothing here either. The gate does not trust another layer to
145
+ have already enforced the rule it is itself stating.
121
146
 
122
147
  A third gate applies to tasks carrying a `module` payload (the
123
148
  "python -m <allowlisted module>" tier): the claiming node must not be
@@ -254,14 +279,80 @@ class IsolationAwarePlacement(PlacementPolicy):
254
279
  already holds the other half of a verification pair — and the point of
255
280
  the pair is that the node cannot tell it is in one.
256
281
 
282
+ A seventh gate applies to tasks whose payload names a `pool`: the
283
+ claiming node's `capabilities.pools` must list it. It takes the
284
+ argv/local-data/gpu/exclude_nodes polarity — **fail closed** — and for
285
+ the sharpest reason of the seven: pool jobs are precisely the ones that
286
+ CARRY `allowFallback`, so a task that slipped this gate would not just
287
+ misplace, it would run UNSANDBOXED on a machine outside the trust
288
+ boundary the waiver assumed. Checked before the isolation block for the
289
+ same reason the gates above are — but here the ordering is load-bearing
290
+ twice over, since this gate and the waiver interact directly rather than
291
+ merely sharing a payload:
292
+
293
+ - The requirement must be a non-empty `str`. `None` means no pool was
294
+ asked for and the task places like any pre-pools job; anything else
295
+ typed (an `int`, a `list`, `True`, or the empty string) is a
296
+ type-confused requirement and makes the task ineligible everywhere,
297
+ exactly as a non-`int` `gpus` or a non-list `exclude_nodes` does.
298
+ - `capabilities` may be absent or type-confused; read as membership in
299
+ no pool rather than allowed to raise, via `isinstance`, not
300
+ `capabilities.get(...) or {}` — a string `capabilities` value has no
301
+ `.get` and must fail closed rather than crash the predicate, the same
302
+ pattern the gpu gate uses for the same reason.
303
+ - The advertisement must be a genuine *list* of names. Absent, `None`, a
304
+ bare string, a dict, or a bare `int` all count as serving no pool. A
305
+ bare string deserves the same suspicion `local_datasets` earns:
306
+ substring membership would let one node's advertisement quietly match
307
+ a pool it never joined.
308
+ - Every member must be a `str`. A `None`, an `int`, or a nested object
309
+ means the stamp was built wrong, and the pool it meant to serve is
310
+ exactly the one a plain membership test would then get wrong — the
311
+ same shape of failure `exclude_nodes` refuses for the same reason.
312
+ The whole node is refused rather than the one bad member ignored.
313
+
314
+ `allowFallback` does not waive this gate, and this is the one case in
315
+ the class where saying so is not enough — the test that pins it
316
+ (`test_allow_fallback_does_not_waive_the_pool_gate`) is the argument
317
+ itself, not a restatement of it. Every other gate's waiver-immunity is
318
+ "the waiver covers the sandbox tier and nothing else, so it has nothing
319
+ to say here." This gate's is stronger: pool-scoped jobs are the ones
320
+ that set `allowFallback: true` in the first place, trading the
321
+ sandboxed-container guarantee for a trusted-pool machine that runs their
322
+ argv directly. If this gate waived on `allowFallback`, the exact tasks
323
+ carrying that trade would be the ones allowed to escape the pool
324
+ boundary that made the trade acceptable, and they would land unsandboxed
325
+ on a stranger's machine — the design's worst failure mode, reached by
326
+ reading the one waiver already present on every task this gate exists to
327
+ confine.
328
+
257
329
  Everything genuinely standard keeps the fail-open placement default."""
258
330
 
259
331
  def eligible(self, task: TaskSpec, node: NodeView) -> bool:
260
332
  # Checked before the allowFallback waiver below: the waiver relaxes
261
333
  # the sandbox-tier requirement, and must never be readable as
262
334
  # permission to run argv on a node with no argv runner.
263
- if "argv" in task.payload and node.get("argv_capable") is not True:
264
- return False
335
+ if "argv" in task.payload:
336
+ if node.get("argv_capable") is True:
337
+ pass # the containerised argv contract — always acceptable
338
+ else:
339
+ # Trusted-pool alternative: the host OPERATOR opted into
340
+ # unsandboxed pool argv (`flashnode work --runner trusted`).
341
+ # Three legs, each `is`-checked, each fails closed. The pool
342
+ # leg here is a guard, not the boundary — the seventh gate
343
+ # independently confines the task to pool members; this leg
344
+ # exists so a waiver that somehow escaped compile/recipe
345
+ # coupling still unlocks nothing outside a pool.
346
+ task_pool = task.payload.get("pool")
347
+ isolation_payload = task.payload.get("isolation")
348
+ if not (
349
+ isinstance(task_pool, str)
350
+ and task_pool
351
+ and isinstance(isolation_payload, dict)
352
+ and isolation_payload.get("allowFallback") is True
353
+ and node.get("unsandboxed_argv_capable") is True
354
+ ):
355
+ return False
265
356
  # Availability gate, mirrored polarity from the argv gate above —
266
357
  # see the class docstring. An argv-only volunteer poisons every
267
358
  # module job in the pool otherwise: it claims, ArgvDockerRunner
@@ -322,6 +413,30 @@ class IsolationAwarePlacement(PlacementPolicy):
322
413
  return False # cannot answer "is this you?" ⇒ do not risk it
323
414
  if node_id in excluded:
324
415
  return False
416
+ # Fail-closed like every gate above, and checked before the
417
+ # allowFallback waiver below for the sharpest reason yet: pool jobs
418
+ # are exactly the ones that CARRY the waiver, so a pool task that
419
+ # slipped this gate would run unsandboxed on a machine outside the
420
+ # trust boundary that made the waiver acceptable.
421
+ required_pool = task.payload.get("pool")
422
+ if required_pool is not None:
423
+ if not isinstance(required_pool, str) or not required_pool:
424
+ return False # type-confused requirement ⇒ fail closed, no crash
425
+ capabilities = node.get("capabilities")
426
+ # isinstance, not `or {}` — a string capabilities value has no
427
+ # `.get` and must fail closed rather than crash the predicate.
428
+ advertised = (
429
+ capabilities.get("pools") if isinstance(capabilities, dict) else None
430
+ )
431
+ if not isinstance(advertised, list):
432
+ return False # absent/type-confused ⇒ serves no pool
433
+ if not all(isinstance(p, str) for p in advertised):
434
+ # A non-name member means the stamp was built wrong; the pool
435
+ # it meant to serve is precisely what a membership test would
436
+ # now get wrong. Refuse the node, not the one member.
437
+ return False
438
+ if required_pool not in advertised:
439
+ return False
325
440
  isolation = task.payload.get("isolation")
326
441
  if isolation is None:
327
442
  return True # no isolation payload ⇒ standard, runs anywhere
@@ -171,6 +171,8 @@ def expand_tasks(job_id: str, spec: JobSpec) -> list[TaskSpec]:
171
171
  }
172
172
  if checkpoint is not None:
173
173
  payload["checkpoint"] = checkpoint
174
+ if spec.spec.placement.pool != "any":
175
+ payload["pool"] = spec.spec.placement.pool
174
176
  tasks.append(
175
177
  TaskSpec(
176
178
  task_id=task_id,
@@ -208,6 +210,17 @@ def _expand_kmeans(job_id: str, spec: JobSpec) -> list[TaskSpec]:
208
210
  tasks = []
209
211
  for i, shard_uri in enumerate(shards):
210
212
  task_id = f"it{iteration:02d}-shard-{i:03d}"
213
+ payload = {
214
+ "module": "flashml_workloads.kmeans_shard",
215
+ "params": {"centroids": centroids},
216
+ "inputs": {"shard": shard_uri},
217
+ "output_prefix": f"jobs/{job_id}/{task_id}/",
218
+ "task_id": task_id,
219
+ "image": spec.spec.image.reference,
220
+ "isolation": isolation,
221
+ }
222
+ if spec.spec.placement.pool != "any":
223
+ payload["pool"] = spec.spec.placement.pool
211
224
  tasks.append(
212
225
  TaskSpec(
213
226
  task_id=task_id,
@@ -215,15 +228,7 @@ def _expand_kmeans(job_id: str, spec: JobSpec) -> list[TaskSpec]:
215
228
  commit_key=f"jobs/{job_id}/{task_id}/metrics.json",
216
229
  max_attempts=spec.spec.retryPolicy.maxTaskAttempts,
217
230
  lease_seconds=_lease_seconds(p, 60.0),
218
- payload={
219
- "module": "flashml_workloads.kmeans_shard",
220
- "params": {"centroids": centroids},
221
- "inputs": {"shard": shard_uri},
222
- "output_prefix": f"jobs/{job_id}/{task_id}/",
223
- "task_id": task_id,
224
- "image": spec.spec.image.reference,
225
- "isolation": isolation,
226
- },
231
+ payload=payload,
227
232
  )
228
233
  )
229
234
  return tasks
@@ -273,6 +278,17 @@ def _expand_fedavg(job_id: str, spec: JobSpec) -> list[TaskSpec]:
273
278
  params = {k: p[k] for k in worker_keys}
274
279
  params.update({"round": int(p.get("round", 0)),
275
280
  "shard": shard, "num_shards": num_shards})
281
+ payload = {
282
+ "module": "flashml_workloads.fedavg_worker",
283
+ "params": params,
284
+ "inputs": inputs,
285
+ "output_prefix": f"jobs/{job_id}/{task_id}/",
286
+ "task_id": task_id,
287
+ "image": spec.spec.image.reference,
288
+ "isolation": isolation,
289
+ }
290
+ if spec.spec.placement.pool != "any":
291
+ payload["pool"] = spec.spec.placement.pool
276
292
  tasks.append(
277
293
  TaskSpec(
278
294
  task_id=task_id,
@@ -280,15 +296,7 @@ def _expand_fedavg(job_id: str, spec: JobSpec) -> list[TaskSpec]:
280
296
  commit_key=f"jobs/{job_id}/{task_id}/metrics.json",
281
297
  max_attempts=spec.spec.retryPolicy.maxTaskAttempts,
282
298
  lease_seconds=_lease_seconds(p, 120.0),
283
- payload={
284
- "module": "flashml_workloads.fedavg_worker",
285
- "params": params,
286
- "inputs": inputs,
287
- "output_prefix": f"jobs/{job_id}/{task_id}/",
288
- "task_id": task_id,
289
- "image": spec.spec.image.reference,
290
- "isolation": isolation,
291
- },
299
+ payload=payload,
292
300
  )
293
301
  )
294
302
  return tasks
@@ -633,6 +641,19 @@ def build_router(state: ModeAState) -> APIRouter:
633
641
  if entry is None:
634
642
  raise HTTPException(status_code=404, detail=f"unknown node {node_id} — register first")
635
643
  entry.last_heartbeat = hb.timestamp
644
+ if hb.pools is not None:
645
+ # Server-stamped membership refresh (cloud proxy). A list, even
646
+ # empty, replaces the registration's pools wholesale; None means
647
+ # no statement. Replace the capabilities object rather than
648
+ # mutating it in place so the registration model stays the
649
+ # single source the claim node view dumps from.
650
+ entry.registration = entry.registration.model_copy(
651
+ update={
652
+ "capabilities": entry.registration.capabilities.model_copy(
653
+ update={"pools": list(hb.pools)}
654
+ )
655
+ }
656
+ )
636
657
  return {"status": "ok"}
637
658
 
638
659
  @router.get("/nodes")
@@ -657,6 +678,7 @@ def build_router(state: ModeAState) -> APIRouter:
657
678
  "node_id": req.node_id,
658
679
  "sandbox_capable": entry.registration.sandbox_capable,
659
680
  "argv_capable": entry.registration.argv_capable,
681
+ "unsandboxed_argv_capable": entry.registration.unsandboxed_argv_capable,
660
682
  "module_capable": entry.registration.module_capable,
661
683
  # Names only — the host paths behind them never reach us, and the
662
684
  # placement gate needs the names to place local-data work at all.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashruntime
3
- Version: 0.4.2
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
@@ -121,13 +121,17 @@ tests/test_monitor_sampler.py
121
121
  tests/test_placement_exclusion.py
122
122
  tests/test_placement_gpu.py
123
123
  tests/test_placement_local_data.py
124
+ tests/test_placement_pool.py
125
+ tests/test_placement_trusted_argv.py
124
126
  tests/test_planner.py
125
127
  tests/test_protocol_evidence.py
126
128
  tests/test_protocol_gpu.py
127
129
  tests/test_protocol_local_datasets.py
130
+ tests/test_protocol_pools.py
128
131
  tests/test_protocol_v1alpha1.py
129
132
  tests/test_recipe_gpu.py
130
133
  tests/test_recipe_local_inputs.py
134
+ tests/test_recipe_pool.py
131
135
  tests/test_recipes_command.py
132
136
  tests/test_recovery.py
133
137
  tests/test_run_json.py
@@ -5,6 +5,18 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "flashruntime"
8
+ # 0.4.3 adds team pools: NodeCapabilities.pools + NodeHeartbeat.pools
9
+ # (stamped server-side by the cloud proxy, never by agents),
10
+ # NodeRegistration.unsandboxed_argv_capable (operator opt-in, fail closed),
11
+ # PlacementSpec.pool widened from a dead closed Literal to str, the seventh
12
+ # placement gate, and the trusted-pool argv alternative. Fedavg round bodies
13
+ # carry placement.pool so pool confinement survives round boundaries.
14
+ # Additive and optional on the wire, so the compatibility range does not
15
+ # move — the bump is 0.4.1's rule again: bump on any wire-visible change,
16
+ # even additive.
17
+ # Agents need nothing from this release unless they opt into
18
+ # `--runner trusted` (flashnode 0.3.4, whose floor moves to >=0.4.3).
19
+ #
8
20
  # 0.4.2 adds ExecutionEvidence and CompleteRequest.evidence — the agent's
9
21
  # self-report at commit time. Additive and OPTIONAL on the wire, so the
10
22
  # compatibility range does not move and an agent that predates it keeps
@@ -34,7 +46,7 @@ name = "flashruntime"
34
46
  # must pin a range that includes it. (0.3.0 was the first published release;
35
47
  # 0.1.0 and 0.2.0 were declared here but never reached PyPI.) Released from
36
48
  # the Zolli-Labs/flashml monorepo by pushing a `flashruntime-vX.Y.Z` tag.
37
- version = "0.4.2"
49
+ version = "0.4.3"
38
50
  description = "Open fault-tolerant distributed ML runtime: strategy planning, job protocol, leases, checkpointing, and recovery across heterogeneous compute."
39
51
  readme = "README.md"
40
52
  # PEP 639 SPDX expression (string form) — replaces the deprecated
@@ -558,6 +558,53 @@ def test_image_and_isolation_tier_are_settable_by_the_caller():
558
558
  assert spec["isolation"]["tier"] == "sandboxed"
559
559
 
560
560
 
561
+ def test_pool_travels_through_round_bodies_to_task_payloads():
562
+ """Pool confinement must survive the round boundary.
563
+
564
+ The cloud API calls `run_fedavg` directly to drive every round of a
565
+ federated job — round 1+ bodies are built here, by `_round_body`, never
566
+ by `CommandRecipe.expand`. Without this stamp a pool-scoped job would
567
+ revert to `pool: "any"` on round 1, exactly the "unstamped hop" bug the
568
+ four expander stamps in recipes/command.py and service/modea.py exist to
569
+ prevent (tests/test_recipe_pool.py) — this is that same bug's fifth
570
+ hop, one `run_fedavg` alone can produce."""
571
+ from flashruntime.protocol.v1alpha1 import JobSpec
572
+ from flashruntime.service.modea import expand_tasks
573
+
574
+ fake = FakeCoordinator({0: [(0, 1.0, 10), (1, 1.0, 10)]})
575
+ run_fedavg(fake, rounds=1, num_shards=2, min_participants=2,
576
+ worker_params=_params(), pool="p-1",
577
+ initial_weights={"w": {"shape": [1], "data": [0.0]}})
578
+ body = fake.submitted[0][1]
579
+ assert body["spec"]["placement"]["pool"] == "p-1"
580
+
581
+ # The full round-boundary hop: what the driver submits is what the
582
+ # service actually expands, not a hand-built intermediate.
583
+ spec = JobSpec.model_validate(body)
584
+ tasks = expand_tasks("job-x", spec)
585
+ assert tasks, "expected at least one task"
586
+ assert all(t.payload["pool"] == "p-1" for t in tasks)
587
+
588
+
589
+ def test_no_pool_leaves_placement_absent_byte_compatible_with_today():
590
+ """`pool=None` (the default) must reproduce exactly today's body — no
591
+ `placement` key at all, and no `pool` in any task payload."""
592
+ from flashruntime.protocol.v1alpha1 import JobSpec
593
+ from flashruntime.service.modea import expand_tasks
594
+
595
+ fake = FakeCoordinator({0: [(0, 1.0, 10), (1, 1.0, 10)]})
596
+ run_fedavg(fake, rounds=1, num_shards=2, min_participants=2,
597
+ worker_params=_params(),
598
+ initial_weights={"w": {"shape": [1], "data": [0.0]}})
599
+ body = fake.submitted[0][1]
600
+ assert "placement" not in body["spec"]
601
+
602
+ spec = JobSpec.model_validate(body)
603
+ tasks = expand_tasks("job-x", spec)
604
+ assert tasks, "expected at least one task"
605
+ assert all("pool" not in t.payload for t in tasks)
606
+
607
+
561
608
  def test_an_image_without_a_tag_is_rejected():
562
609
  fake = FakeCoordinator({0: [(0, 1.0, 10)]})
563
610
  with pytest.raises(ValueError, match="repository:tag"):