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.
- {flashruntime-0.4.1/flashruntime.egg-info → flashruntime-0.4.3}/PKG-INFO +1 -1
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/fedavg_driver.py +63 -8
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/fedavg_weights.py +148 -3
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/protocol/v1alpha1.py +84 -1
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recipes/command.py +15 -6
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/scheduler/__init__.py +186 -7
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/modea.py +55 -18
- {flashruntime-0.4.1 → flashruntime-0.4.3/flashruntime.egg-info}/PKG-INFO +1 -1
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime.egg-info/SOURCES.txt +6 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/pyproject.toml +26 -1
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_fedavg_driver.py +125 -0
- flashruntime-0.4.3/tests/test_fedavg_weights.py +475 -0
- flashruntime-0.4.3/tests/test_placement_exclusion.py +296 -0
- flashruntime-0.4.3/tests/test_placement_pool.py +170 -0
- flashruntime-0.4.3/tests/test_placement_trusted_argv.py +142 -0
- flashruntime-0.4.3/tests/test_protocol_evidence.py +279 -0
- flashruntime-0.4.3/tests/test_protocol_pools.py +57 -0
- flashruntime-0.4.3/tests/test_recipe_pool.py +50 -0
- flashruntime-0.4.1/tests/test_fedavg_weights.py +0 -227
- {flashruntime-0.4.1 → flashruntime-0.4.3}/LICENSE +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/README.md +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/fedavg_worker.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/kmeans_driver.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/kmeans_shard.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/sgd_trainer.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/sharded_kmeans.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashml_workloads/sklearn_trial.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/artifacts/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/artifacts/store.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/backends/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/backends/base.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/backends/kuberay.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/checkpoint/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/checkpoint/catalog.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/checkpoint/local.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/checkpoint/store.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/integrations/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/integrations/huggingface.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/integrations/pytorch.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/integrations/sklearn.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/launchers/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/launchers/local.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/leases/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/leases/manager.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/leases/sqlite_store.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/leases/store.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/monitor/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/monitor/sampler.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/candidates.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/catalog.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/comm.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/explain.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/memory.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/resolve.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/selector.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/planner/timecost.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/profiling/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/protocol/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/protocol/plan_v1alpha1.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/providers/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/py.typed +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recipes/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recovery/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recovery/policy.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recovery/signals.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/recovery/taxonomy.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/sdk.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/app.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/auth.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/checkpoints.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/cli.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/dashboard.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/service/ledger.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/strategies/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/strategies/command.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/torch/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/benchmarks.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/get-started.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/index.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/cli.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/search-index.json +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/flowmap.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/page.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/server.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/viewer/state.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/workloads/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime/workloads/command.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime.egg-info/dependency_links.txt +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime.egg-info/entry_points.txt +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime.egg-info/requires.txt +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/flashruntime.egg-info/top_level.txt +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/setup.cfg +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_auto_recovery.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_benchmarks.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_checkpoint.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_checkpoint_local.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_cli_submit.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_documentation.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_examples_e2e.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_fedavg_convergence.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_fedavg_worker.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_gpu_e2e.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_integrations.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_interfaces.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_kmeans_leases.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_kmeans_workload.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_kuberay_backend.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_launcher_local.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_leases.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_leases_scope.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_leases_sqlite.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_monitor_sampler.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_placement_gpu.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_placement_local_data.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_planner.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_protocol_gpu.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_protocol_local_datasets.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_protocol_v1alpha1.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_recipe_gpu.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_recipe_local_inputs.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_recipes_command.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_recovery.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_run_json.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_scheduler_isolation.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_sdk_submit.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_auth.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_auth_startup.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_checkpoints.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_command_recipe.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_delegation.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_fedavg.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_modea.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_service_write_scope.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_sgd_trainer.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_strategy_command.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_torch_helper.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_viewer_flowmap.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_viewer_page.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_viewer_server.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.3}/tests/test_viewer_state.py +0 -0
- {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.
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
|
441
|
-
*default* builder and are ignored when `build_round` is
|
|
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 =
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
86
|
-
|
|
87
|
-
#
|
|
88
|
-
#
|
|
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
|
|
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,
|