flashruntime 0.5.0__tar.gz → 0.6.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 (191) hide show
  1. {flashruntime-0.5.0/flashruntime.egg-info → flashruntime-0.6.0}/PKG-INFO +1 -1
  2. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/protocol/v1alpha1.py +11 -0
  3. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/recipes/command.py +37 -0
  4. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/scheduler/__init__.py +92 -0
  5. {flashruntime-0.5.0 → flashruntime-0.6.0/flashruntime.egg-info}/PKG-INFO +1 -1
  6. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime.egg-info/SOURCES.txt +3 -0
  7. {flashruntime-0.5.0 → flashruntime-0.6.0}/pyproject.toml +1 -1
  8. flashruntime-0.6.0/tests/test_placement_datasets.py +354 -0
  9. flashruntime-0.6.0/tests/test_protocol_datasets.py +28 -0
  10. flashruntime-0.6.0/tests/test_recipe_datasets.py +95 -0
  11. {flashruntime-0.5.0 → flashruntime-0.6.0}/LICENSE +0 -0
  12. {flashruntime-0.5.0 → flashruntime-0.6.0}/README.md +0 -0
  13. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashml_workloads/__init__.py +0 -0
  14. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashml_workloads/chunks.py +0 -0
  15. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashml_workloads/fedavg_driver.py +0 -0
  16. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashml_workloads/fedavg_weights.py +0 -0
  17. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashml_workloads/fedavg_worker.py +0 -0
  18. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashml_workloads/kmeans_driver.py +0 -0
  19. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashml_workloads/kmeans_shard.py +0 -0
  20. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashml_workloads/sgd_trainer.py +0 -0
  21. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashml_workloads/sharded_kmeans.py +0 -0
  22. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashml_workloads/sklearn_trial.py +0 -0
  23. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/__init__.py +0 -0
  24. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/artifacts/__init__.py +0 -0
  25. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/artifacts/store.py +0 -0
  26. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/backends/__init__.py +0 -0
  27. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/backends/base.py +0 -0
  28. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/backends/kuberay.py +0 -0
  29. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/checkpoint/__init__.py +0 -0
  30. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/checkpoint/catalog.py +0 -0
  31. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/checkpoint/local.py +0 -0
  32. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/checkpoint/store.py +0 -0
  33. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/envelope.py +0 -0
  34. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/images/TAG +0 -0
  35. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/images/__init__.py +0 -0
  36. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/images/python-slim.txt +0 -0
  37. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/images/pytorch-cpu.txt +0 -0
  38. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/images/pytorch-cuda.txt +0 -0
  39. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/images/sklearn.txt +0 -0
  40. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/integrations/__init__.py +0 -0
  41. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/integrations/huggingface.py +0 -0
  42. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/integrations/pytorch.py +0 -0
  43. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/integrations/sklearn.py +0 -0
  44. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/launchers/__init__.py +0 -0
  45. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/launchers/local.py +0 -0
  46. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/leases/__init__.py +0 -0
  47. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/leases/manager.py +0 -0
  48. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/leases/sqlite_store.py +0 -0
  49. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/leases/store.py +0 -0
  50. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/monitor/__init__.py +0 -0
  51. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/monitor/sampler.py +0 -0
  52. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/planner/__init__.py +0 -0
  53. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/planner/candidates.py +0 -0
  54. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/planner/catalog.py +0 -0
  55. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/planner/comm.py +0 -0
  56. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/planner/explain.py +0 -0
  57. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/planner/memory.py +0 -0
  58. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/planner/resolve.py +0 -0
  59. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/planner/selector.py +0 -0
  60. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/planner/timecost.py +0 -0
  61. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/profiling/__init__.py +0 -0
  62. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/protocol/__init__.py +0 -0
  63. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/protocol/plan_v1alpha1.py +0 -0
  64. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/providers/__init__.py +0 -0
  65. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/py.typed +0 -0
  66. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/recipes/__init__.py +0 -0
  67. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/recipes/partition.py +0 -0
  68. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/recovery/__init__.py +0 -0
  69. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/recovery/policy.py +0 -0
  70. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/recovery/signals.py +0 -0
  71. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/recovery/taxonomy.py +0 -0
  72. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/reduce.py +0 -0
  73. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/retention.py +0 -0
  74. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/sdk.py +0 -0
  75. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/service/__init__.py +0 -0
  76. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/service/app.py +0 -0
  77. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/service/auth.py +0 -0
  78. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/service/checkpoints.py +0 -0
  79. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/service/cli.py +0 -0
  80. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/service/dashboard.py +0 -0
  81. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/service/ledger.py +0 -0
  82. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/service/modea.py +0 -0
  83. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/strategies/__init__.py +0 -0
  84. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/strategies/command.py +0 -0
  85. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/torch/__init__.py +0 -0
  86. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/validators.py +0 -0
  87. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/__init__.py +0 -0
  88. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/benchmarks.html +0 -0
  89. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
  90. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/get-started.html +0 -0
  91. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
  92. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
  93. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
  94. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
  95. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
  96. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/index.html +0 -0
  97. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/reference/cli.html +0 -0
  98. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
  99. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
  100. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
  101. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/search-index.json +0 -0
  102. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
  103. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
  104. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
  105. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/flowmap.py +0 -0
  106. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/page.py +0 -0
  107. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/server.py +0 -0
  108. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/viewer/state.py +0 -0
  109. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/workloads/__init__.py +0 -0
  110. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime/workloads/command.py +0 -0
  111. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime.egg-info/dependency_links.txt +0 -0
  112. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime.egg-info/entry_points.txt +0 -0
  113. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime.egg-info/requires.txt +0 -0
  114. {flashruntime-0.5.0 → flashruntime-0.6.0}/flashruntime.egg-info/top_level.txt +0 -0
  115. {flashruntime-0.5.0 → flashruntime-0.6.0}/setup.cfg +0 -0
  116. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_auto_recovery.py +0 -0
  117. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_benchmarks.py +0 -0
  118. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_checkpoint.py +0 -0
  119. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_checkpoint_local.py +0 -0
  120. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_chunks.py +0 -0
  121. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_cli_submit.py +0 -0
  122. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_cost_benchmark.py +0 -0
  123. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_declared_validators.py +0 -0
  124. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_documentation.py +0 -0
  125. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_envelope.py +0 -0
  126. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_examples_e2e.py +0 -0
  127. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_fedavg_convergence.py +0 -0
  128. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_fedavg_driver.py +0 -0
  129. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_fedavg_weights.py +0 -0
  130. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_fedavg_worker.py +0 -0
  131. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_gpu_e2e.py +0 -0
  132. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_headline.py +0 -0
  133. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_image_manifest_lookup.py +0 -0
  134. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_integrations.py +0 -0
  135. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_interfaces.py +0 -0
  136. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_job_state_partial.py +0 -0
  137. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_kmeans_leases.py +0 -0
  138. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_kmeans_workload.py +0 -0
  139. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_kuberay_backend.py +0 -0
  140. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_launcher_local.py +0 -0
  141. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_lease_seconds_bound.py +0 -0
  142. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_leases.py +0 -0
  143. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_leases_scope.py +0 -0
  144. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_leases_sqlite.py +0 -0
  145. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_monitor_sampler.py +0 -0
  146. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_placement_dependencies.py +0 -0
  147. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_placement_exclusion.py +0 -0
  148. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_placement_gpu.py +0 -0
  149. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_placement_local_data.py +0 -0
  150. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_placement_pool.py +0 -0
  151. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_placement_reliability.py +0 -0
  152. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_placement_trusted_argv.py +0 -0
  153. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_planner.py +0 -0
  154. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_protocol_evidence.py +0 -0
  155. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_protocol_gpu.py +0 -0
  156. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_protocol_local_datasets.py +0 -0
  157. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_protocol_pools.py +0 -0
  158. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_protocol_v1alpha1.py +0 -0
  159. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_recipe_gpu.py +0 -0
  160. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_recipe_local_inputs.py +0 -0
  161. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_recipe_partition.py +0 -0
  162. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_recipe_pool.py +0 -0
  163. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_recipes_command.py +0 -0
  164. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_recovery.py +0 -0
  165. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_reduce.py +0 -0
  166. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_retention.py +0 -0
  167. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_run_json.py +0 -0
  168. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_scheduler_isolation.py +0 -0
  169. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_sdk_submit.py +0 -0
  170. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_artifact_delete.py +0 -0
  171. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_auth.py +0 -0
  172. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_auth_startup.py +0 -0
  173. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_checkpoints.py +0 -0
  174. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_command_recipe.py +0 -0
  175. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_delegation.py +0 -0
  176. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_fedavg.py +0 -0
  177. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_job_result.py +0 -0
  178. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_modea.py +0 -0
  179. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_reliability.py +0 -0
  180. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_validate_output.py +0 -0
  181. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_service_write_scope.py +0 -0
  182. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_sgd_trainer.py +0 -0
  183. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_strategy_command.py +0 -0
  184. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_submit_envelope.py +0 -0
  185. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_submit_retention.py +0 -0
  186. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_torch_helper.py +0 -0
  187. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_viewer_flowmap.py +0 -0
  188. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_viewer_page.py +0 -0
  189. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_viewer_server.py +0 -0
  190. {flashruntime-0.5.0 → flashruntime-0.6.0}/tests/test_viewer_state.py +0 -0
  191. {flashruntime-0.5.0 → flashruntime-0.6.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.5.0
3
+ Version: 0.6.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
@@ -332,6 +332,17 @@ class NodeCapabilities(BaseModel):
332
332
  #: this runtime cannot parse — never a guess. Placement reads the LENGTH
333
333
  #: of this list and nothing else in v1.
334
334
  gpus: list[GpuInfo] = Field(default_factory=list)
335
+ #: Bytes this host will lend to cached dataset shards. Advertised so the
336
+ #: control plane can refuse a job at SUBMIT time when no host in the pool
337
+ #: can hold a slice — twenty machines each downloading for forty minutes
338
+ #: and then refusing is the failure this exists to replace.
339
+ #:
340
+ #: Defaults to 0, and 0 means "send me no dataset work". Fail closed, the
341
+ #: same polarity as `can_install_dependencies`: an agent whose
342
+ #: registration predates this field has nowhere to put a shard and must
343
+ #: never be handed one. Bytes rather than GB to match `memory_bytes` —
344
+ #: one unit for every capacity on this model.
345
+ dataset_cache_bytes: int = 0
335
346
  #: Team pools this node serves, as pool ids minted by the cloud control
336
347
  #: plane. Stamped SERVER-SIDE by the cloud API's agent proxy from the
337
348
  #: machine owner's memberships — an agent's self-reported value is
@@ -66,6 +66,18 @@ class CommandRecipe(WorkloadRecipe):
66
66
  )
67
67
  elif not all(isinstance(d, str) for d in extra_deps):
68
68
  problems.append("'extra_dependencies' must be a list of strings")
69
+ slices = params.get("dataset_slices")
70
+ if slices is not None:
71
+ if not isinstance(slices, list) or isinstance(slices, (str, bytes)):
72
+ problems.append(
73
+ "'dataset_slices' must be a list with one entry per task "
74
+ "(a list, not a single string)"
75
+ )
76
+ elif not all(isinstance(s, list) for s in slices):
77
+ problems.append(
78
+ "'dataset_slices' must be a list of per-task lists, one "
79
+ "list of dataset slices for each task"
80
+ )
69
81
  problems.extend(self._unpack_problems(params))
70
82
  problems.extend(partition_problems(params))
71
83
  problems.extend(declaration_problems(params.get("validators")))
@@ -239,6 +251,31 @@ class CommandRecipe(WorkloadRecipe):
239
251
  # hop have tests that pass while it is broken, because each
240
252
  # constructs the payload directly.
241
253
  payload["extra_dependencies"] = list(p["extra_dependencies"])
254
+ if p.get("dataset_slices") is not None:
255
+ # The ONLY per-task payload key in this method. Every other
256
+ # forward above copies one value to every task; each task
257
+ # fetches a DIFFERENT slice of the manifest, so this one is
258
+ # indexed by task position exactly as `task_params` is.
259
+ #
260
+ # Computed by the flashml-cloud compiler and opaque here:
261
+ # the byte-weighted mapping lives beside `fleet_shape` in
262
+ # that repo's `elastic.py`, and a second implementation here
263
+ # is the drift that module's docstring warns about.
264
+ #
265
+ # Same failure shape as `local_inputs` and `dependencies`:
266
+ # drop this line and every host fetches nothing, the workload
267
+ # dies on a missing path, and both ends' unit tests still
268
+ # pass because each constructs the payload directly.
269
+ slices = p["dataset_slices"]
270
+ if i >= len(slices):
271
+ raise ValueError(
272
+ f"task {i}: 'dataset_slices' has {len(slices)} "
273
+ f"entr{'y' if len(slices) == 1 else 'ies'} but this "
274
+ f"job expanded to more tasks — every task needs a "
275
+ f"slice, even an empty one"
276
+ )
277
+ # `list(...)` so a payload never aliases the caller's spec.
278
+ payload["datasets"] = list(slices[i])
242
279
  gpus = spec.spec.resources.gpuPerTask
243
280
  if gpus:
244
281
  # The GPU requirement is the ONLY payload key sourced from
@@ -386,6 +386,56 @@ class IsolationAwarePlacement(PlacementPolicy):
386
386
  waiver relaxes the sandbox tier and nothing else. No waiver makes a
387
387
  read-only container with no network able to run `pip install`.
388
388
 
389
+ A ninth gate applies to tasks whose payload carries `datasets`: the sum
390
+ of every slice entry's `size` must fit inside the claiming node's
391
+ `capabilities.dataset_cache_bytes`. This is CAPACITY, not capability —
392
+ every tier can fetch, because the AGENT fetches before the sandbox
393
+ closes, so unlike `extra_dependencies` there is no tier that physically
394
+ cannot. The only question is whether the bytes fit.
395
+
396
+ Not to be confused with the fourth gate. `local_inputs`/`local_datasets`
397
+ asks whether a host already HOLDS named data it will never upload; this
398
+ one asks whether a host has ROOM for data it is about to fetch from a
399
+ public origin. Different payload key, different capability, different
400
+ question.
401
+
402
+ It takes the argv/local-data/gpu/exclude_nodes/pool/dependencies
403
+ polarity — **fail closed** — and every leg of it:
404
+
405
+ - A node advertising nothing (an agent whose registration predates the
406
+ field) is refused, and so is one advertising `0`, which is the model
407
+ default and means "send me no dataset work". A shard with nowhere to
408
+ go is a task that dies after a forty-minute download rather than one
409
+ that never started, and twenty machines doing that at once is the
410
+ failure the advertisement exists to prevent.
411
+ - The advertisement must be a genuine `int`. `bool` is excluded
412
+ explicitly for the reason the `gpus` gate excludes it: it is an `int`
413
+ subclass, so a `dataset_cache_bytes: true` typo would read as the
414
+ capacity 1.
415
+ - `capabilities` itself may be absent or type-confused; that is read as
416
+ advertising nothing rather than allowed to raise. `isinstance`, never
417
+ `(node.get("capabilities") or {})` — a string `capabilities` value has
418
+ no `.get` and would crash the predicate inside the claim path, taking
419
+ the whole claim down rather than one task.
420
+ - A non-dict slice, a non-dict entry, or an entry whose `size` is not a
421
+ non-negative `int` makes the task ineligible everywhere. The manifest
422
+ is submitter-controlled, so an unreadable size must NOT contribute 0 to
423
+ the total: that is precisely how a hostile slice would buy itself an
424
+ unbounded fetch on a node that advertised a modest cache.
425
+ - The requirement is the sum over every entry of every slice. Checking
426
+ per-dataset, or against the largest entry, would place a task that then
427
+ fills a volunteer's disk halfway through the fetch.
428
+ - Empty and absent both mean "declares no data": `if
429
+ task.payload.get("datasets")` treats `None` and `[]` alike, so every
430
+ job deployed today is completely unaffected. A slice that is PRESENT
431
+ but happens to carry no entries is still dataset work and still needs
432
+ a readable advertisement — the fail-closed leg is the advertisement,
433
+ not the total.
434
+
435
+ `allowFallback` does not waive this gate either. It waives the sandbox
436
+ tier and nothing else; a submitter's isolation posture has nothing to say
437
+ about how much disk a stranger's machine offered.
438
+
389
439
  Everything genuinely standard keeps the fail-open placement default."""
390
440
 
391
441
  def eligible(self, task: TaskSpec, node: NodeView) -> bool:
@@ -516,6 +566,48 @@ class IsolationAwarePlacement(PlacementPolicy):
516
566
  if task.payload.get("extra_dependencies"):
517
567
  if node.get("can_install_dependencies") is not True:
518
568
  return False
569
+ # Capacity, not capability, and the only gate in this class that is.
570
+ # Every tier's AGENT has network and a writable disk before the
571
+ # sandbox closes, so unlike `extra_dependencies` there is no tier
572
+ # that physically cannot fetch — the only question is whether the
573
+ # bytes fit. Distinct from the `local_inputs` gate above: that one
574
+ # asks whether a host already HOLDS named data, this one whether it
575
+ # has ROOM for data it is about to fetch.
576
+ #
577
+ # Fail-closed on an absent or type-confused advertisement, and
578
+ # checked before the allowFallback waiver below for the same reason
579
+ # as every gate above: the waiver relaxes the sandbox tier and has
580
+ # nothing to say about how much disk a stranger's machine offered.
581
+ if task.payload.get("datasets"):
582
+ needed = 0
583
+ for dataset in task.payload["datasets"]:
584
+ if not isinstance(dataset, dict):
585
+ return False # type-confused slice ⇒ fail closed, no crash
586
+ for entry in dataset.get("entries") or []:
587
+ size = entry.get("size") if isinstance(entry, dict) else None
588
+ # A non-int size is a malformed slice, not a free one:
589
+ # letting it contribute 0 is how a hostile manifest buys
590
+ # an unbounded fetch on a modest cache. `bool` is an
591
+ # `int` subclass, so `size: true` is excluded explicitly.
592
+ if (
593
+ not isinstance(size, int)
594
+ or isinstance(size, bool)
595
+ or size < 0
596
+ ):
597
+ return False
598
+ needed += size
599
+ capabilities = node.get("capabilities")
600
+ # isinstance, not `or {}` — a string capabilities value has no
601
+ # `.get` and must fail closed rather than crash the predicate.
602
+ advertised = (
603
+ capabilities.get("dataset_cache_bytes")
604
+ if isinstance(capabilities, dict)
605
+ else None
606
+ )
607
+ if not isinstance(advertised, int) or isinstance(advertised, bool):
608
+ return False # absent/type-confused ⇒ advertises nothing
609
+ if needed > advertised:
610
+ return False
519
611
  isolation = task.payload.get("isolation")
520
612
  if isolation is None:
521
613
  return True # no isolation payload ⇒ standard, runs anywhere
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashruntime
3
- Version: 0.5.0
3
+ Version: 0.6.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
@@ -138,6 +138,7 @@ tests/test_leases.py
138
138
  tests/test_leases_scope.py
139
139
  tests/test_leases_sqlite.py
140
140
  tests/test_monitor_sampler.py
141
+ tests/test_placement_datasets.py
141
142
  tests/test_placement_dependencies.py
142
143
  tests/test_placement_exclusion.py
143
144
  tests/test_placement_gpu.py
@@ -146,11 +147,13 @@ tests/test_placement_pool.py
146
147
  tests/test_placement_reliability.py
147
148
  tests/test_placement_trusted_argv.py
148
149
  tests/test_planner.py
150
+ tests/test_protocol_datasets.py
149
151
  tests/test_protocol_evidence.py
150
152
  tests/test_protocol_gpu.py
151
153
  tests/test_protocol_local_datasets.py
152
154
  tests/test_protocol_pools.py
153
155
  tests/test_protocol_v1alpha1.py
156
+ tests/test_recipe_datasets.py
154
157
  tests/test_recipe_gpu.py
155
158
  tests/test_recipe_local_inputs.py
156
159
  tests/test_recipe_partition.py
@@ -46,7 +46,7 @@ name = "flashruntime"
46
46
  # must pin a range that includes it. (0.3.0 was the first published release;
47
47
  # 0.1.0 and 0.2.0 were declared here but never reached PyPI.) Released from
48
48
  # the Zolli-Labs/flashml monorepo by pushing a `flashruntime-vX.Y.Z` tag.
49
- version = "0.5.0"
49
+ version = "0.6.0"
50
50
  description = "Open fault-tolerant distributed ML runtime: strategy planning, job protocol, leases, checkpointing, and recovery across heterogeneous compute."
51
51
  readme = "README.md"
52
52
  # PEP 639 SPDX expression (string form) — replaces the deprecated
@@ -0,0 +1,354 @@
1
+ """The ninth placement gate: a node must have room for the slice it is
2
+ offered — `capabilities.dataset_cache_bytes` must cover the sum of every
3
+ declared entry's `size` (AGENTS.md rule 3).
4
+
5
+ Capacity, not capability. Unlike installing dependencies — which the
6
+ container tiers physically cannot do — every tier's AGENT fetches before the
7
+ sandbox closes, so there is no tier that *cannot* hold a shard. The only
8
+ question this gate asks is whether the bytes fit.
9
+
10
+ Fail closed, taking the argv/local-data/gpu polarity: a node advertising
11
+ nothing (an agent whose registration predates the field) holds no dataset
12
+ work. A shard with nowhere to go is a task that dies after a forty-minute
13
+ download rather than one that never started.
14
+
15
+ Not to be confused with `local_inputs`/`local_datasets`
16
+ (`test_placement_local_data.py`), which is a different feature with its own
17
+ gate: that one asks whether a host already HOLDS named data it will never
18
+ upload; this one asks whether a host has room for data it is about to fetch.
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import pytest
24
+
25
+
26
+ def _entry(size=100, **over):
27
+ entry = {
28
+ "path": "a.parquet",
29
+ "url": "https://origin.invalid/a.parquet",
30
+ "size": size,
31
+ "integrity": {"kind": "sha256", "value": "aa"},
32
+ }
33
+ entry.update(over)
34
+ return entry
35
+
36
+
37
+ def _dataset(*sizes, name="d", **over):
38
+ dataset = {"name": name, "split": "shard",
39
+ "entries": [_entry(size) for size in sizes]}
40
+ dataset.update(over)
41
+ return dataset
42
+
43
+
44
+ def _task(datasets, **payload_extra):
45
+ """A task carrying `datasets` RAW, so tests can poison it with values the
46
+ compiler would never emit (the trick `_local_task` plays on `local_inputs`
47
+ and `_task_raw` plays on the isolation payload)."""
48
+ from flashruntime.protocol.v1alpha1 import TaskSpec
49
+
50
+ payload = {"module": "flashml_workloads.sklearn_trial"}
51
+ if datasets is not None:
52
+ payload["datasets"] = datasets
53
+ payload.update(payload_extra)
54
+ return TaskSpec(
55
+ task_id="task-000", job_id="job-a", commit_key="job-a/task-000/m.json",
56
+ payload=payload,
57
+ )
58
+
59
+
60
+ def _node(cache_bytes=None, **over):
61
+ """A node view shaped like the claim endpoint's. `cache_bytes` defaults to
62
+ ABSENT (not 0) so a bare `_node()` models an agent whose registration
63
+ predates the field — the fail-closed case."""
64
+ node = {"node_id": "n1", "sandbox_capable": True, "capabilities": {}}
65
+ if cache_bytes is not None:
66
+ node["capabilities"] = {"dataset_cache_bytes": cache_bytes}
67
+ node.update(over)
68
+ return node
69
+
70
+
71
+ def test_a_node_with_room_is_eligible():
72
+ from flashruntime.scheduler import IsolationAwarePlacement
73
+
74
+ assert IsolationAwarePlacement().eligible(
75
+ _task([_dataset(100)]), _node(1000)) is True
76
+
77
+
78
+ def test_a_node_without_room_is_not():
79
+ from flashruntime.scheduler import IsolationAwarePlacement
80
+
81
+ assert IsolationAwarePlacement().eligible(
82
+ _task([_dataset(2000)]), _node(1000)) is False
83
+
84
+
85
+ def test_an_exact_fit_is_eligible():
86
+ """The boundary is `needed > advertised`, not `>=`: a node that advertised
87
+ exactly this many bytes said yes to exactly this many bytes."""
88
+ from flashruntime.scheduler import IsolationAwarePlacement
89
+
90
+ assert IsolationAwarePlacement().eligible(
91
+ _task([_dataset(1000)]), _node(1000)) is True
92
+
93
+
94
+ def test_the_requirement_is_the_sum_over_every_entry_of_every_dataset():
95
+ """One entry fits; the whole slice does not. Summing per-dataset or
96
+ checking only the largest entry would place a task that then fills a
97
+ volunteer's disk halfway through the fetch."""
98
+ from flashruntime.scheduler import IsolationAwarePlacement
99
+
100
+ policy = IsolationAwarePlacement()
101
+ task = _task([_dataset(400, 400, name="d1"), _dataset(400, name="d2")])
102
+ assert policy.eligible(task, _node(1000)) is False
103
+ assert policy.eligible(task, _node(1200)) is True
104
+
105
+
106
+ def test_a_node_predating_the_field_holds_no_dataset_work():
107
+ """Fail closed — an absent capability reads as 'advertises nothing', never
108
+ as 'advertises everything'."""
109
+ from flashruntime.scheduler import IsolationAwarePlacement
110
+
111
+ assert IsolationAwarePlacement().eligible(_task([_dataset(1)]), _node()) is False
112
+
113
+
114
+ def test_a_zero_advertisement_holds_no_dataset_work():
115
+ """`0` is the model default and it means 'send me no dataset work' — the
116
+ key being PRESENT must not read as consent."""
117
+ from flashruntime.scheduler import IsolationAwarePlacement
118
+
119
+ assert IsolationAwarePlacement().eligible(_task([_dataset(1)]), _node(0)) is False
120
+
121
+
122
+ @pytest.mark.parametrize(
123
+ "capabilities",
124
+ [
125
+ pytest.param("not-a-dict", id="bare-string"),
126
+ pytest.param(None, id="explicit-none"),
127
+ pytest.param(["dataset_cache_bytes"], id="list"),
128
+ pytest.param(17179869184, id="bare-int"),
129
+ ],
130
+ )
131
+ def test_a_type_confused_capabilities_value_does_not_raise(capabilities):
132
+ """`(node.get("capabilities") or {})` would crash the predicate on a
133
+ string — it has no `.get`, and a crash inside the claim path takes the
134
+ whole claim down rather than one task. The house rule is `isinstance`
135
+ (documented at `scheduler/__init__.py:234-236`), and the reading is
136
+ 'advertises nothing'."""
137
+ from flashruntime.scheduler import IsolationAwarePlacement
138
+
139
+ node = _node()
140
+ node["capabilities"] = capabilities
141
+ assert IsolationAwarePlacement().eligible(_task([_dataset(1)]), node) is False
142
+
143
+
144
+ @pytest.mark.parametrize(
145
+ "advertised",
146
+ [
147
+ pytest.param("17179869184", id="numeric-string"),
148
+ pytest.param(16.0, id="float"),
149
+ pytest.param(None, id="explicit-none"),
150
+ pytest.param([16], id="list"),
151
+ pytest.param(True, id="truthy-boolean"),
152
+ ],
153
+ )
154
+ def test_a_non_int_advertisement_holds_no_dataset_work(advertised):
155
+ """A capacity that is not a genuine `int` is an advertisement nobody can
156
+ act on. `bool` is named explicitly because it is an `int` subclass: a
157
+ `dataset_cache_bytes: true` typo must not read as the capacity 1."""
158
+ from flashruntime.scheduler import IsolationAwarePlacement
159
+
160
+ assert IsolationAwarePlacement().eligible(
161
+ _task([_dataset(1)]), _node(advertised)) is False
162
+
163
+
164
+ @pytest.mark.parametrize(
165
+ "size",
166
+ [
167
+ pytest.param("100", id="numeric-string"),
168
+ pytest.param(100.5, id="float"),
169
+ pytest.param(None, id="explicit-none"),
170
+ pytest.param(True, id="truthy-boolean"),
171
+ pytest.param(-1, id="negative"),
172
+ ],
173
+ )
174
+ def test_a_non_int_entry_size_is_a_malformed_slice_not_a_free_one(size):
175
+ """The manifest is submitter-controlled. An unreadable size must make the
176
+ task unplaceable, never contribute 0 to the total — the latter is how a
177
+ hostile slice buys itself an unbounded fetch on a node that advertised a
178
+ modest cache."""
179
+ from flashruntime.scheduler import IsolationAwarePlacement
180
+
181
+ task = _task([{"name": "d", "split": "shard", "entries": [_entry(size)]}])
182
+ assert IsolationAwarePlacement().eligible(task, _node(1_000_000)) is False
183
+
184
+
185
+ def test_an_entry_missing_its_size_is_refused():
186
+ from flashruntime.scheduler import IsolationAwarePlacement
187
+
188
+ entry = _entry()
189
+ del entry["size"]
190
+ task = _task([{"name": "d", "split": "shard", "entries": [entry]}])
191
+ assert IsolationAwarePlacement().eligible(task, _node(1_000_000)) is False
192
+
193
+
194
+ @pytest.mark.parametrize(
195
+ "entry",
196
+ [
197
+ pytest.param("a.parquet", id="bare-string"),
198
+ pytest.param(None, id="explicit-none"),
199
+ pytest.param(100, id="bare-int"),
200
+ ],
201
+ )
202
+ def test_a_non_dict_entry_is_refused(entry):
203
+ from flashruntime.scheduler import IsolationAwarePlacement
204
+
205
+ task = _task([{"name": "d", "split": "shard", "entries": [entry]}])
206
+ assert IsolationAwarePlacement().eligible(task, _node(1_000_000)) is False
207
+
208
+
209
+ @pytest.mark.parametrize(
210
+ "dataset",
211
+ [
212
+ pytest.param("imdb", id="bare-string"),
213
+ pytest.param(None, id="explicit-none"),
214
+ pytest.param([{"path": "a.parquet"}], id="nested-list"),
215
+ ],
216
+ )
217
+ def test_a_non_dict_dataset_is_refused(dataset):
218
+ """A slice that is not a mapping was built wrong, and the gate must say so
219
+ by refusing rather than by raising inside the claim path."""
220
+ from flashruntime.scheduler import IsolationAwarePlacement
221
+
222
+ assert IsolationAwarePlacement().eligible(
223
+ _task([dataset]), _node(1_000_000)) is False
224
+
225
+
226
+ @pytest.mark.parametrize(
227
+ "datasets",
228
+ [
229
+ pytest.param("imdb", id="bare-string"),
230
+ pytest.param({"name": "imdb"}, id="single-dict-not-a-list"),
231
+ ],
232
+ )
233
+ def test_a_type_confused_datasets_payload_is_ineligible_everywhere(datasets):
234
+ """Present but not a list of slices: fail closed on every node, including
235
+ one with room to spare, and never crash the predicate."""
236
+ from flashruntime.scheduler import IsolationAwarePlacement
237
+
238
+ assert IsolationAwarePlacement().eligible(
239
+ _task(datasets), _node(1_000_000)) is False
240
+
241
+
242
+ def test_a_task_without_datasets_is_unaffected():
243
+ """Every job deployed today. The gate must be invisible to them — including
244
+ on a node that advertises no cache at all."""
245
+ from flashruntime.scheduler import IsolationAwarePlacement
246
+
247
+ policy = IsolationAwarePlacement()
248
+ assert policy.eligible(_task(None), _node()) is True
249
+ assert policy.eligible(_task(None), _node(1000)) is True
250
+
251
+
252
+ def test_an_empty_datasets_list_requires_nothing():
253
+ """Absent and empty mean the same thing here, exactly as they do for
254
+ `extra_dependencies`: a job that declares no data runs anywhere."""
255
+ from flashruntime.scheduler import IsolationAwarePlacement
256
+
257
+ assert IsolationAwarePlacement().eligible(_task([]), _node()) is True
258
+
259
+
260
+ def test_a_slice_with_no_entries_still_needs_an_advertisement():
261
+ """`needed == 0` is not a licence: a node that advertised nothing has said
262
+ it wants no dataset work, and a slice that happens to be empty this time is
263
+ still dataset work. Fail closed on the advertisement, not on the total."""
264
+ from flashruntime.scheduler import IsolationAwarePlacement
265
+
266
+ policy = IsolationAwarePlacement()
267
+ empty = _task([{"name": "d", "split": "shard", "entries": []}])
268
+ assert policy.eligible(empty, _node()) is False
269
+ assert policy.eligible(empty, _node(1000)) is True
270
+
271
+
272
+ def test_allow_fallback_does_not_waive_this_gate():
273
+ """The waiver covers the sandbox tier and nothing else. A submitter's
274
+ isolation posture has nothing to say about how much disk a stranger's
275
+ machine offered."""
276
+ from flashruntime.scheduler import IsolationAwarePlacement
277
+
278
+ task = _task(
279
+ [_dataset(2000)],
280
+ pool="p1",
281
+ isolation={"tier": "sandboxed", "allowFallback": True},
282
+ )
283
+ node = _node(1000)
284
+ node["capabilities"]["pools"] = ["p1"]
285
+ assert IsolationAwarePlacement().eligible(task, node) is False
286
+
287
+
288
+ def test_claim_over_a_dataset_queue_serves_the_node_with_room():
289
+ """A slice too large for the asking node must not block the queue: the next
290
+ clean task is still leased, and the dataset task waits for a host that fits
291
+ it."""
292
+ from flashruntime.leases import LeaseManager
293
+ from flashruntime.protocol.v1alpha1 import TaskSpec
294
+ from flashruntime.scheduler import IsolationAwarePlacement
295
+
296
+ mgr = LeaseManager()
297
+ mgr.add_task(_task([_dataset(2000)])) # head of queue, needs room
298
+ mgr.add_task(
299
+ TaskSpec(task_id="task-001", job_id="job-a", commit_key="job-a/task-001/m.json")
300
+ )
301
+ policy = IsolationAwarePlacement()
302
+
303
+ lease = mgr.claim("n1", policy=policy, node=_node(1000))
304
+ assert lease is not None
305
+ assert lease.task_id == "task-001"
306
+
307
+ lease2 = mgr.claim("n2", policy=policy, node=_node(1_000_000, node_id="n2"))
308
+ assert lease2 is not None
309
+ assert lease2.task_id == "task-000"
310
+
311
+
312
+ def test_claim_endpoint_leases_dataset_work_only_to_a_host_with_room():
313
+ """End to end over the REAL claim endpoint, not a hand-built node dict.
314
+
315
+ Every other test in this file hands `eligible()` a dictionary shaped like
316
+ the claim endpoint's node view. None of them touches the code that
317
+ actually BUILDS that view (`service/modea.py`), so all of them would pass
318
+ while the endpoint omitted `dataset_cache_bytes` — the gate would read an
319
+ absent capability on EVERY node and refuse every dataset task everywhere.
320
+ Fail closed, and also permanently stuck: inert in production, green in CI.
321
+ """
322
+ import pathlib
323
+
324
+ import fastapi
325
+ from fastapi.testclient import TestClient
326
+
327
+ from flashruntime.leases import LeaseManager
328
+ from flashruntime.service.modea import ModeAState, build_router
329
+
330
+ state = ModeAState(LeaseManager(), artifacts_dir=pathlib.Path("/tmp"))
331
+ app = fastapi.FastAPI()
332
+ app.include_router(build_router(state))
333
+ client = TestClient(app)
334
+ state.manager.add_task(_task([_dataset(4096)]))
335
+
336
+ def register(node_id: str, cache_bytes: int):
337
+ r = client.post(
338
+ "/v1alpha1/nodes/register",
339
+ json={
340
+ "node_id": node_id, "kubernetes_node": "", "hostname": node_id,
341
+ "capabilities": {"dataset_cache_bytes": cache_bytes},
342
+ },
343
+ )
344
+ assert r.status_code == 200
345
+
346
+ register("no-room-node", 0)
347
+ register("roomy-node", 1024 ** 3)
348
+
349
+ assert client.post(
350
+ "/v1alpha1/leases/claim", json={"node_id": "no-room-node"}
351
+ ).status_code == 204
352
+ r = client.post("/v1alpha1/leases/claim", json={"node_id": "roomy-node"})
353
+ assert r.status_code == 200, r.text
354
+ assert r.json()["task_id"] == "task-000"
@@ -0,0 +1,28 @@
1
+ """Wire field for dataset cache capacity (AGENTS.md rule 3: fail closed).
2
+
3
+ Bytes, not GB, to match `memory_bytes` — one unit for every capacity on
4
+ this model. Zero means "send me no dataset work", which is what an agent
5
+ predating the field must be read as saying.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from flashruntime.protocol.v1alpha1 import NodeCapabilities, NodeRegistration
11
+
12
+
13
+ def test_dataset_cache_bytes_defaults_to_zero():
14
+ assert NodeCapabilities().dataset_cache_bytes == 0
15
+
16
+
17
+ def test_dataset_cache_bytes_round_trips():
18
+ caps = NodeCapabilities.model_validate({"dataset_cache_bytes": 17179869184})
19
+ assert caps.model_dump()["dataset_cache_bytes"] == 17179869184
20
+
21
+
22
+ def test_old_wire_shapes_still_validate():
23
+ """An agent on 0.5.0 sends none of this; nothing may break."""
24
+ reg = NodeRegistration.model_validate(
25
+ {"node_id": "n", "kubernetes_node": "", "hostname": "h",
26
+ "capabilities": {"cpu_cores": 4}}
27
+ )
28
+ assert reg.capabilities.dataset_cache_bytes == 0