flashruntime 0.3.0__tar.gz → 0.4.0__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 (148) hide show
  1. {flashruntime-0.3.0/flashruntime.egg-info → flashruntime-0.4.0}/PKG-INFO +1 -1
  2. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/protocol/v1alpha1.py +10 -0
  3. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/recipes/command.py +14 -0
  4. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/scheduler/__init__.py +41 -0
  5. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/service/modea.py +3 -0
  6. {flashruntime-0.3.0 → flashruntime-0.4.0/flashruntime.egg-info}/PKG-INFO +1 -1
  7. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime.egg-info/SOURCES.txt +3 -0
  8. {flashruntime-0.3.0 → flashruntime-0.4.0}/pyproject.toml +6 -5
  9. flashruntime-0.4.0/tests/test_placement_local_data.py +205 -0
  10. flashruntime-0.4.0/tests/test_protocol_local_datasets.py +52 -0
  11. flashruntime-0.4.0/tests/test_recipe_local_inputs.py +68 -0
  12. {flashruntime-0.3.0 → flashruntime-0.4.0}/LICENSE +0 -0
  13. {flashruntime-0.3.0 → flashruntime-0.4.0}/README.md +0 -0
  14. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashml_workloads/__init__.py +0 -0
  15. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashml_workloads/fedavg_driver.py +0 -0
  16. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashml_workloads/fedavg_weights.py +0 -0
  17. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashml_workloads/fedavg_worker.py +0 -0
  18. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashml_workloads/kmeans_driver.py +0 -0
  19. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashml_workloads/kmeans_shard.py +0 -0
  20. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashml_workloads/sgd_trainer.py +0 -0
  21. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashml_workloads/sharded_kmeans.py +0 -0
  22. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashml_workloads/sklearn_trial.py +0 -0
  23. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/__init__.py +0 -0
  24. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/artifacts/__init__.py +0 -0
  25. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/artifacts/store.py +0 -0
  26. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/backends/__init__.py +0 -0
  27. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/backends/base.py +0 -0
  28. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/backends/kuberay.py +0 -0
  29. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/checkpoint/__init__.py +0 -0
  30. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/checkpoint/catalog.py +0 -0
  31. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/checkpoint/local.py +0 -0
  32. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/checkpoint/store.py +0 -0
  33. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/integrations/__init__.py +0 -0
  34. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/integrations/huggingface.py +0 -0
  35. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/integrations/pytorch.py +0 -0
  36. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/integrations/sklearn.py +0 -0
  37. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/launchers/__init__.py +0 -0
  38. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/launchers/local.py +0 -0
  39. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/leases/__init__.py +0 -0
  40. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/leases/manager.py +0 -0
  41. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/leases/sqlite_store.py +0 -0
  42. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/leases/store.py +0 -0
  43. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/monitor/__init__.py +0 -0
  44. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/monitor/sampler.py +0 -0
  45. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/planner/__init__.py +0 -0
  46. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/planner/candidates.py +0 -0
  47. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/planner/catalog.py +0 -0
  48. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/planner/comm.py +0 -0
  49. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/planner/explain.py +0 -0
  50. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/planner/memory.py +0 -0
  51. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/planner/resolve.py +0 -0
  52. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/planner/selector.py +0 -0
  53. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/planner/timecost.py +0 -0
  54. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/profiling/__init__.py +0 -0
  55. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/protocol/__init__.py +0 -0
  56. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/protocol/plan_v1alpha1.py +0 -0
  57. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/providers/__init__.py +0 -0
  58. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/py.typed +0 -0
  59. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/recipes/__init__.py +0 -0
  60. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/recovery/__init__.py +0 -0
  61. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/recovery/policy.py +0 -0
  62. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/recovery/signals.py +0 -0
  63. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/recovery/taxonomy.py +0 -0
  64. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/sdk.py +0 -0
  65. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/service/__init__.py +0 -0
  66. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/service/app.py +0 -0
  67. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/service/auth.py +0 -0
  68. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/service/checkpoints.py +0 -0
  69. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/service/cli.py +0 -0
  70. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/service/dashboard.py +0 -0
  71. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/service/ledger.py +0 -0
  72. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/strategies/__init__.py +0 -0
  73. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/strategies/command.py +0 -0
  74. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/torch/__init__.py +0 -0
  75. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/__init__.py +0 -0
  76. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/benchmarks.html +0 -0
  77. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
  78. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/get-started.html +0 -0
  79. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
  80. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
  81. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
  82. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
  83. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
  84. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/index.html +0 -0
  85. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/reference/cli.html +0 -0
  86. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
  87. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
  88. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
  89. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/search-index.json +0 -0
  90. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
  91. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
  92. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
  93. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/flowmap.py +0 -0
  94. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/page.py +0 -0
  95. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/server.py +0 -0
  96. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/viewer/state.py +0 -0
  97. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/workloads/__init__.py +0 -0
  98. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime/workloads/command.py +0 -0
  99. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime.egg-info/dependency_links.txt +0 -0
  100. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime.egg-info/entry_points.txt +0 -0
  101. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime.egg-info/requires.txt +0 -0
  102. {flashruntime-0.3.0 → flashruntime-0.4.0}/flashruntime.egg-info/top_level.txt +0 -0
  103. {flashruntime-0.3.0 → flashruntime-0.4.0}/setup.cfg +0 -0
  104. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_auto_recovery.py +0 -0
  105. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_benchmarks.py +0 -0
  106. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_checkpoint.py +0 -0
  107. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_checkpoint_local.py +0 -0
  108. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_cli_submit.py +0 -0
  109. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_documentation.py +0 -0
  110. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_examples_e2e.py +0 -0
  111. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_fedavg_convergence.py +0 -0
  112. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_fedavg_driver.py +0 -0
  113. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_fedavg_weights.py +0 -0
  114. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_fedavg_worker.py +0 -0
  115. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_gpu_e2e.py +0 -0
  116. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_integrations.py +0 -0
  117. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_interfaces.py +0 -0
  118. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_kmeans_leases.py +0 -0
  119. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_kmeans_workload.py +0 -0
  120. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_kuberay_backend.py +0 -0
  121. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_launcher_local.py +0 -0
  122. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_leases.py +0 -0
  123. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_leases_scope.py +0 -0
  124. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_leases_sqlite.py +0 -0
  125. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_monitor_sampler.py +0 -0
  126. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_planner.py +0 -0
  127. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_protocol_v1alpha1.py +0 -0
  128. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_recipes_command.py +0 -0
  129. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_recovery.py +0 -0
  130. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_run_json.py +0 -0
  131. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_scheduler_isolation.py +0 -0
  132. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_sdk_submit.py +0 -0
  133. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_service_auth.py +0 -0
  134. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_service_auth_startup.py +0 -0
  135. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_service_checkpoints.py +0 -0
  136. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_service_command_recipe.py +0 -0
  137. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_service_delegation.py +0 -0
  138. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_service_fedavg.py +0 -0
  139. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_service_modea.py +0 -0
  140. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_service_write_scope.py +0 -0
  141. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_sgd_trainer.py +0 -0
  142. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_strategy_command.py +0 -0
  143. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_torch_helper.py +0 -0
  144. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_viewer_flowmap.py +0 -0
  145. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_viewer_page.py +0 -0
  146. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_viewer_server.py +0 -0
  147. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_viewer_state.py +0 -0
  148. {flashruntime-0.3.0 → flashruntime-0.4.0}/tests/test_workloads_command.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashruntime
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -297,6 +297,16 @@ class NodeRegistration(BaseModel):
297
297
  #: keeps receiving module work; only a node that explicitly opts into
298
298
  #: an argv-only runner sets this False (see scheduler.IsolationAwarePlacement).
299
299
  module_capable: bool = True
300
+ #: Names of datasets this node holds locally and offers to jobs. Names
301
+ #: only — never paths: a host path is host-private and must not travel to
302
+ #: the coordinator. Defaults to an empty list, so a node advertises
303
+ #: nothing until its operator explicitly maps a dataset: an agent that has
304
+ #: not opted in must never be eligible for local-data work, and an absent
305
+ #: field on an already-deployed agent's registration must read as "offers
306
+ #: nothing" rather than "offers anything" (security fields fail closed).
307
+ #: `default_factory` and not a bare `[]` — one shared list would let a
308
+ #: single node's advertisement leak into every other node's registration.
309
+ local_datasets: list[str] = Field(default_factory=list)
300
310
  pool: str = "local"
301
311
  runtime_profile: str = "kubernetes"
302
312
  labels: dict[str, str] = Field(default_factory=dict)
@@ -146,6 +146,20 @@ class CommandRecipe(WorkloadRecipe):
146
146
  # for byte. Emitting `[]` would mean the same thing today but
147
147
  # would stop exercising that path.
148
148
  payload["unpack_inputs"] = list(p["unpack_inputs"])
149
+ if p.get("local_inputs") is not None:
150
+ # Labels for host-supplied directories. This forward is what
151
+ # makes the local-data feature real: `IsolationAwarePlacement`
152
+ # reads `task.payload["local_inputs"]` to decide eligibility,
153
+ # and flashnode reads it again to know what to mount.
154
+ #
155
+ # Dropping it does NOT fail closed. The gate sees a task
156
+ # requiring nothing, places it on any node, and flashnode
157
+ # mounts nothing — so the task runs without the data it asked
158
+ # for. Both ends of this hop have tests that pass while it is
159
+ # broken, because each constructs the payload directly.
160
+ #
161
+ # `list(...)` so a payload never aliases the caller's spec.
162
+ payload["local_inputs"] = list(p["local_inputs"])
149
163
  tasks.append(
150
164
  TaskSpec(
151
165
  task_id=task_id,
@@ -143,6 +143,35 @@ class IsolationAwarePlacement(PlacementPolicy):
143
143
  cut every already-deployed node off from module work the moment this
144
144
  field shipped, without anyone having opted out of anything.
145
145
 
146
+ A fourth gate applies to tasks whose payload lists `local_inputs` (data
147
+ the host holds and never uploads): the claiming node must advertise
148
+ EVERY one of those names in its `local_datasets` capability. It shares
149
+ the argv gate's fail-closed polarity, not the module gate's, because
150
+ the host's data is the thing being protected — an absent, `None`, or
151
+ wrongly-typed capability counts as NOT capable:
152
+
153
+ - The capability must be a genuine *list* of names. A bare string does
154
+ not count, even when it looks right: ``"patients" in "patients"`` is
155
+ True in Python, so accepting a string would let a node advertise
156
+ every dataset whose name is a substring of anything it said. That is
157
+ the same class of type confusion `sandbox_capable is True` avoids
158
+ for booleans.
159
+ - The requirement itself must be a list. A `local_inputs` payload that
160
+ is present but not a list (e.g. the bare string ``"patients"``) makes
161
+ the task ineligible everywhere — fail closed without crashing the
162
+ predicate, exactly as a non-dict isolation payload does.
163
+ - An empty `local_inputs` requires nothing and so runs anywhere, like
164
+ tier ``"standard"``.
165
+
166
+ The `allowFallback` waiver does NOT apply here, for the same reason it
167
+ does not waive the argv gate: it waives the sandbox *tier* requirement
168
+ and nothing else. A waiver is the submitter's statement about their own
169
+ isolation posture; the local-data gate protects the HOST's data, which
170
+ the submitter has no standing to waive. A task placed on a node that
171
+ never advertised the dataset would fail on the agent anyway (the label
172
+ is not in its map) — failing closed here makes it fail before anything
173
+ touches the data, and before an attempt is burned.
174
+
146
175
  Everything genuinely standard keeps the fail-open placement default."""
147
176
 
148
177
  def eligible(self, task: TaskSpec, node: NodeView) -> bool:
@@ -158,6 +187,18 @@ class IsolationAwarePlacement(PlacementPolicy):
158
187
  # into the same node's path until attempts are exhausted.
159
188
  if "module" in task.payload and node.get("module_capable") is False:
160
189
  return False
190
+ # Fail-closed like the argv gate, and checked before the allowFallback
191
+ # waiver below for the same reason: the waiver covers the sandbox tier
192
+ # only, and a submitter cannot waive their way onto a host's data.
193
+ local_inputs = task.payload.get("local_inputs")
194
+ if local_inputs is not None:
195
+ if not isinstance(local_inputs, list):
196
+ return False # type-confused requirement ⇒ fail closed, no crash
197
+ advertised = node.get("local_datasets")
198
+ if local_inputs and not isinstance(advertised, list):
199
+ return False # absent/None/type-confused capability ⇒ not capable
200
+ if any(name not in advertised for name in local_inputs):
201
+ return False
161
202
  isolation = task.payload.get("isolation")
162
203
  if isolation is None:
163
204
  return True # no isolation payload ⇒ standard, runs anywhere
@@ -643,6 +643,9 @@ def build_router(state: ModeAState) -> APIRouter:
643
643
  "sandbox_capable": entry.registration.sandbox_capable,
644
644
  "argv_capable": entry.registration.argv_capable,
645
645
  "module_capable": entry.registration.module_capable,
646
+ # Names only — the host paths behind them never reach us, and the
647
+ # placement gate needs the names to place local-data work at all.
648
+ "local_datasets": entry.registration.local_datasets,
646
649
  "capabilities": entry.registration.capabilities.model_dump(),
647
650
  }
648
651
  lease = manager.claim(
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashruntime
3
- Version: 0.3.0
3
+ Version: 0.4.0
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
@@ -118,8 +118,11 @@ tests/test_leases.py
118
118
  tests/test_leases_scope.py
119
119
  tests/test_leases_sqlite.py
120
120
  tests/test_monitor_sampler.py
121
+ tests/test_placement_local_data.py
121
122
  tests/test_planner.py
123
+ tests/test_protocol_local_datasets.py
122
124
  tests/test_protocol_v1alpha1.py
125
+ tests/test_recipe_local_inputs.py
123
126
  tests/test_recipes_command.py
124
127
  tests/test_recovery.py
125
128
  tests/test_run_json.py
@@ -5,11 +5,12 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "flashruntime"
8
- # 0.3.0 is the FIRST published release. 0.1.0 and 0.2.0 were declared here but
9
- # never reached PyPI — the name 404'd until now — so nothing is superseded and
10
- # no installed version is being replaced. Released from the Zolli-Labs/flashml
11
- # monorepo by pushing a `flashruntime-v0.3.0` tag.
12
- version = "0.3.0"
8
+ # 0.4.0 adds NodeRegistration.local_datasets — a new wire field, so consumers
9
+ # must pin a range that includes it. flashnode’s floor moves to >=0.4,<0.5.
10
+ # (0.3.0 was the first published release; 0.1.0 and 0.2.0 were declared here
11
+ # but never reached PyPI.) Released from the Zolli-Labs/flashml monorepo by
12
+ # pushing a `flashruntime-v0.4.0` tag.
13
+ version = "0.4.0"
13
14
  description = "Open fault-tolerant distributed ML runtime: strategy planning, job protocol, leases, checkpointing, and recovery across heterogeneous compute."
14
15
  readme = "README.md"
15
16
  # PEP 639 SPDX expression (string form) — replaces the deprecated
@@ -0,0 +1,205 @@
1
+ """The fourth fail-closed placement gate: a task that names `local_inputs` may
2
+ only be leased to a node advertising every one of those names in its
3
+ `local_datasets` capability (AGENTS.md rule 3).
4
+
5
+ The data never leaves the host, so placement is the only thing standing between
6
+ a task and a machine that cannot serve it. An absent, None, or wrongly-typed
7
+ capability counts as NOT capable, exactly like `sandbox_capable`/`argv_capable`.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import pytest
13
+
14
+
15
+ def _local_task(local_inputs=("patients",), **payload_extra):
16
+ """A task requiring local datasets. `local_inputs` is passed through RAW so
17
+ tests can poison it with type-confused values the protocol would never
18
+ emit (the same trick `_task_raw` plays on the isolation payload)."""
19
+ from flashruntime.protocol.v1alpha1 import TaskSpec
20
+
21
+ payload = {"module": "flashml_workloads.sklearn_trial"}
22
+ if local_inputs is not None:
23
+ payload["local_inputs"] = local_inputs
24
+ payload.update(payload_extra)
25
+ return TaskSpec(
26
+ task_id="task-000", job_id="job-a", commit_key="job-a/task-000/m.json",
27
+ payload=payload,
28
+ )
29
+
30
+
31
+ def test_node_advertising_a_superset_is_eligible():
32
+ from flashruntime.scheduler import IsolationAwarePlacement
33
+
34
+ node = {"node_id": "n1", "local_datasets": ["patients", "labs"]}
35
+ assert IsolationAwarePlacement().eligible(_local_task(["patients"]), node) is True
36
+
37
+
38
+ def test_node_advertising_every_required_name_is_eligible():
39
+ """Two requirements, both advertised — the gate is per-name, not per-task."""
40
+ from flashruntime.scheduler import IsolationAwarePlacement
41
+
42
+ node = {"node_id": "n1", "local_datasets": ["labs", "patients"]}
43
+ task = _local_task(["patients", "labs"])
44
+ assert IsolationAwarePlacement().eligible(task, node) is True
45
+
46
+
47
+ def test_node_advertising_only_some_required_names_is_ineligible():
48
+ from flashruntime.scheduler import IsolationAwarePlacement
49
+
50
+ node = {"node_id": "n1", "local_datasets": ["patients"]}
51
+ task = _local_task(["patients", "labs"])
52
+ assert IsolationAwarePlacement().eligible(task, node) is False
53
+
54
+
55
+ def test_node_advertising_a_different_dataset_is_ineligible():
56
+ from flashruntime.scheduler import IsolationAwarePlacement
57
+
58
+ node = {"node_id": "n1", "local_datasets": ["labs"]}
59
+ assert IsolationAwarePlacement().eligible(_local_task(["patients"]), node) is False
60
+
61
+
62
+ @pytest.mark.parametrize(
63
+ "advertised",
64
+ [
65
+ pytest.param([], id="empty-list"),
66
+ pytest.param(None, id="explicit-none"),
67
+ pytest.param("patients", id="bare-string-type-confusion"),
68
+ pytest.param({"patients": "/srv/data"}, id="dict-type-confusion"),
69
+ pytest.param(("patients",), id="tuple-type-confusion"),
70
+ pytest.param(True, id="truthy-boolean"),
71
+ ],
72
+ )
73
+ def test_ineligible_unless_the_capability_is_a_list_containing_the_name(advertised):
74
+ """Fail closed on absence AND on type confusion. The bare string
75
+ "patients" must not count: `"patients" in "patients"` is True in Python,
76
+ which is exactly the substring trap `sandbox_capable is True` avoids for
77
+ booleans. Only a genuine list of names advertises anything."""
78
+ from flashruntime.scheduler import IsolationAwarePlacement
79
+
80
+ node = {"node_id": "n1", "local_datasets": advertised}
81
+ assert IsolationAwarePlacement().eligible(_local_task(["patients"]), node) is False
82
+
83
+
84
+ def test_node_with_no_local_datasets_field_at_all_is_ineligible():
85
+ """An already-deployed agent whose registration predates the field
86
+ advertises nothing — absent must read as "offers nothing", never as
87
+ "offers anything"."""
88
+ from flashruntime.scheduler import IsolationAwarePlacement
89
+
90
+ node = {"node_id": "n1"} # no local_datasets key at all
91
+ assert IsolationAwarePlacement().eligible(_local_task(["patients"]), node) is False
92
+
93
+
94
+ def test_allow_fallback_cannot_bypass_the_local_data_gate():
95
+ """allowFallback waives the sandbox-tier requirement only. It must not also
96
+ waive the local-data gate — the same reasoning that stops it waiving the
97
+ argv gate: a submitter cannot grant themselves data a host never offered."""
98
+ from flashruntime.scheduler import IsolationAwarePlacement
99
+
100
+ task = _local_task(
101
+ ["patients"], isolation={"tier": "sandboxed", "allowFallback": True}
102
+ )
103
+ node = {"node_id": "n1", "sandbox_capable": False, "local_datasets": []}
104
+ assert IsolationAwarePlacement().eligible(task, node) is False
105
+
106
+
107
+ def test_allow_fallback_does_not_bypass_it_on_a_node_missing_the_field():
108
+ from flashruntime.scheduler import IsolationAwarePlacement
109
+
110
+ task = _local_task(
111
+ ["patients"], isolation={"tier": "sandboxed", "allowFallback": True}
112
+ )
113
+ assert IsolationAwarePlacement().eligible(task, {"node_id": "n1"}) is False
114
+
115
+
116
+ def test_type_confused_local_inputs_is_ineligible_everywhere():
117
+ """A poisoned requirement (present but not a list, e.g. the bare string
118
+ "patients") must fail closed on every node — including one that advertises
119
+ the name — and must never crash the predicate."""
120
+ from flashruntime.scheduler import IsolationAwarePlacement
121
+
122
+ policy = IsolationAwarePlacement()
123
+ node = {"node_id": "n1", "local_datasets": ["patients"]}
124
+ assert policy.eligible(_local_task("patients"), node) is False
125
+ assert policy.eligible(_local_task({"patients": 1}), node) is False
126
+
127
+
128
+ def test_tasks_without_local_inputs_are_unaffected():
129
+ from flashruntime.scheduler import IsolationAwarePlacement
130
+
131
+ policy = IsolationAwarePlacement()
132
+ task = _local_task(None) # no local_inputs key in the payload at all
133
+ assert policy.eligible(task, {"node_id": "n1"}) is True
134
+ assert policy.eligible(task, {"node_id": "n2", "local_datasets": ["labs"]}) is True
135
+
136
+
137
+ def test_empty_local_inputs_list_requires_nothing():
138
+ """`local_inputs: []` is what a job that opted out of the feature compiles
139
+ to — it demands nothing, so it runs anywhere, like tier "standard"."""
140
+ from flashruntime.scheduler import IsolationAwarePlacement
141
+
142
+ assert IsolationAwarePlacement().eligible(_local_task([]), {"node_id": "n1"}) is True
143
+
144
+
145
+ def test_claim_over_a_local_data_queue_serves_the_clean_task():
146
+ """A local-data task at the head of the queue must not block a node that
147
+ cannot serve it — the next clean task is still leased, and the local-data
148
+ task is never mis-leased."""
149
+ from flashruntime.leases import LeaseManager
150
+ from flashruntime.protocol.v1alpha1 import TaskSpec
151
+ from flashruntime.scheduler import IsolationAwarePlacement
152
+
153
+ mgr = LeaseManager()
154
+ mgr.add_task(_local_task(["patients"])) # head of queue, needs data
155
+ mgr.add_task(
156
+ TaskSpec(task_id="task-001", job_id="job-a", commit_key="job-a/task-001/m.json")
157
+ )
158
+ policy = IsolationAwarePlacement()
159
+
160
+ lease = mgr.claim("n1", policy=policy, node={"node_id": "n1"})
161
+ assert lease is not None
162
+ assert lease.task_id == "task-001"
163
+
164
+ holder = {"node_id": "n2", "local_datasets": ["patients"]}
165
+ lease2 = mgr.claim("n2", policy=policy, node=holder)
166
+ assert lease2 is not None
167
+ assert lease2.task_id == "task-000"
168
+
169
+
170
+ def test_claim_endpoint_leases_local_data_work_only_to_the_holder():
171
+ """End to end over the claim endpoint: the registry's claim-time node view
172
+ must forward the advertised names, or the gate reads an absent capability
173
+ on EVERY node and a local-data task becomes unplaceable everywhere — fail
174
+ closed, but also permanently stuck."""
175
+ import pathlib
176
+
177
+ import fastapi
178
+ from fastapi.testclient import TestClient
179
+
180
+ from flashruntime.leases import LeaseManager
181
+ from flashruntime.service.modea import ModeAState, build_router
182
+
183
+ state = ModeAState(LeaseManager(), artifacts_dir=pathlib.Path("/tmp"))
184
+ app = fastapi.FastAPI()
185
+ app.include_router(build_router(state))
186
+ client = TestClient(app)
187
+ state.manager.add_task(_local_task(["patients"]))
188
+
189
+ def register(node_id: str, local_datasets: list[str]):
190
+ r = client.post(
191
+ "/v1alpha1/nodes/register",
192
+ json={
193
+ "node_id": node_id, "kubernetes_node": "", "hostname": node_id,
194
+ "capabilities": {}, "local_datasets": local_datasets,
195
+ },
196
+ )
197
+ assert r.status_code == 200
198
+
199
+ register("no-data-node", [])
200
+ register("holder-node", ["patients", "labs"])
201
+
202
+ assert client.post("/v1alpha1/leases/claim", json={"node_id": "no-data-node"}).status_code == 204
203
+ r = client.post("/v1alpha1/leases/claim", json={"node_id": "holder-node"})
204
+ assert r.status_code == 200
205
+ assert r.json()["task_id"] == "task-000"
@@ -0,0 +1,52 @@
1
+ """`NodeRegistration.local_datasets` — the names of on-host datasets a node
2
+ advertises, and the fail-closed defaulting that governs them.
3
+
4
+ These are protocol tests, not scheduler tests: they pin the *wire* behaviour
5
+ that the placement gate is later allowed to trust. The gate can only fail
6
+ closed if the field itself does.
7
+ """
8
+
9
+ from flashruntime.protocol.v1alpha1 import NodeRegistration
10
+
11
+
12
+ def make_registration(**overrides):
13
+ base = {
14
+ "node_id": "node-a",
15
+ "kubernetes_node": "worker-0",
16
+ "hostname": "worker-0.local",
17
+ "capabilities": {"cpu_cores": 4, "os": "linux", "architecture": "amd64"},
18
+ }
19
+ base.update(overrides)
20
+ return base
21
+
22
+
23
+ def test_local_datasets_defaults_empty_when_absent_from_the_wire():
24
+ # An agent deployed before this field existed sends a registration with no
25
+ # `local_datasets` key at all. It must decode as advertising nothing, so it
26
+ # is never eligible for local-data work until it is upgraded and opts in
27
+ # explicitly (AGENTS.md rule 3: security-relevant fields fail closed).
28
+ reg = NodeRegistration.model_validate(make_registration())
29
+ assert reg.local_datasets == []
30
+
31
+
32
+ def test_local_datasets_round_trips_through_json():
33
+ reg = NodeRegistration.model_validate(
34
+ make_registration(local_datasets=["patients", "labs"])
35
+ )
36
+ restored = NodeRegistration.model_validate_json(reg.model_dump_json())
37
+ assert restored.local_datasets == ["patients", "labs"]
38
+
39
+
40
+ def test_local_datasets_default_is_not_shared_between_instances():
41
+ # A bare `[]` default would be one list shared by every NodeRegistration
42
+ # ever constructed: one node appending to it would silently advertise its
43
+ # datasets on behalf of every other node in the pool. `default_factory`
44
+ # is what stops that, and this test is what stops someone "simplifying"
45
+ # it back.
46
+ first = NodeRegistration.model_validate(make_registration(node_id="node-a"))
47
+ second = NodeRegistration.model_validate(make_registration(node_id="node-b"))
48
+
49
+ assert first.local_datasets is not second.local_datasets
50
+
51
+ first.local_datasets.append("patients")
52
+ assert second.local_datasets == []
@@ -0,0 +1,68 @@
1
+ """`local_inputs` must survive the JobSpec -> task payload hop.
2
+
3
+ The feature is built at both ends of this hop and was broken in the middle.
4
+ `flashml.yaml` puts `local_inputs` into the workload parameters, and
5
+ `IsolationAwarePlacement` reads it from `task.payload` — but `CommandRecipe`
6
+ builds each payload from a fixed key list and dropped every parameter it did
7
+ not recognise, `local_inputs` among them.
8
+
9
+ The failure mode is the dangerous direction. The gate sees no requirement, so
10
+ it does not fail closed and refuse the node — it fails OPEN, places the task
11
+ anywhere, and flashnode mounts nothing. The task then runs without the data it
12
+ asked for.
13
+
14
+ Nothing caught it because both ends' tests construct the intermediate value
15
+ directly: the compile tests assert the parameter lands in the JobSpec, and the
16
+ placement tests build a payload containing `local_inputs` by hand. Each end was
17
+ correct about itself. This test is about the hop between them.
18
+ """
19
+ from __future__ import annotations
20
+
21
+ from flashruntime.protocol.v1alpha1 import JobSpec
22
+ from flashruntime.recipes.command import CommandRecipe
23
+
24
+
25
+ def _spec(**params) -> JobSpec:
26
+ return JobSpec.model_validate({
27
+ "metadata": {"name": "local-data-job"},
28
+ "spec": {
29
+ "image": {"repository": "ghcr.io/zolli-labs/flashml-python-slim",
30
+ "tag": "2026.08.1"},
31
+ "workload": {
32
+ "type": "command",
33
+ "parameters": {"command": ["python", "train.py"], **params},
34
+ },
35
+ "resources": {"minimumWorkers": 1, "maximumWorkers": 1},
36
+ "isolation": {"tier": "sandboxed", "allowFallback": False},
37
+ },
38
+ })
39
+
40
+
41
+ def test_local_inputs_reaches_the_task_payload():
42
+ """Where the placement gate actually reads it."""
43
+ tasks = CommandRecipe().expand("job-1", _spec(local_inputs=["patients"]))
44
+
45
+ assert tasks, "expected at least one task"
46
+ for task in tasks:
47
+ assert task.payload.get("local_inputs") == ["patients"], (
48
+ "the placement gate reads task.payload['local_inputs']; without it "
49
+ "the gate fails OPEN and the task is placed on a node with no data"
50
+ )
51
+
52
+
53
+ def test_absent_local_inputs_stays_absent():
54
+ """Never an empty list — same reasoning as `unpack_inputs` beside it.
55
+
56
+ An omitted key is the path where nothing about placement changes, and
57
+ emitting `[]` would mean the same thing today while quietly ceasing to
58
+ exercise that path."""
59
+ for task in CommandRecipe().expand("job-1", _spec()):
60
+ assert "local_inputs" not in task.payload
61
+
62
+
63
+ def test_local_inputs_is_copied_not_aliased():
64
+ """A payload must not share a mutable list with the caller's spec."""
65
+ names = ["patients"]
66
+ tasks = CommandRecipe().expand("job-1", _spec(local_inputs=names))
67
+ names.append("labs")
68
+ assert tasks[0].payload["local_inputs"] == ["patients"]
File without changes
File without changes
File without changes