flashruntime 0.4.0__tar.gz → 0.4.2__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 (154) hide show
  1. {flashruntime-0.4.0/flashruntime.egg-info → flashruntime-0.4.2}/PKG-INFO +1 -1
  2. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/fedavg_driver.py +33 -2
  3. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/fedavg_weights.py +148 -3
  4. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/protocol/v1alpha1.py +91 -1
  5. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recipes/command.py +20 -0
  6. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/scheduler/__init__.py +123 -0
  7. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/modea.py +15 -0
  8. {flashruntime-0.4.0 → flashruntime-0.4.2/flashruntime.egg-info}/PKG-INFO +1 -1
  9. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime.egg-info/SOURCES.txt +5 -0
  10. {flashruntime-0.4.0 → flashruntime-0.4.2}/pyproject.toml +30 -6
  11. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_fedavg_driver.py +78 -0
  12. flashruntime-0.4.2/tests/test_fedavg_weights.py +475 -0
  13. flashruntime-0.4.2/tests/test_placement_exclusion.py +296 -0
  14. flashruntime-0.4.2/tests/test_placement_gpu.py +301 -0
  15. flashruntime-0.4.2/tests/test_protocol_evidence.py +279 -0
  16. flashruntime-0.4.2/tests/test_protocol_gpu.py +152 -0
  17. flashruntime-0.4.2/tests/test_recipe_gpu.py +129 -0
  18. flashruntime-0.4.0/tests/test_fedavg_weights.py +0 -227
  19. {flashruntime-0.4.0 → flashruntime-0.4.2}/LICENSE +0 -0
  20. {flashruntime-0.4.0 → flashruntime-0.4.2}/README.md +0 -0
  21. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/__init__.py +0 -0
  22. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/fedavg_worker.py +0 -0
  23. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/kmeans_driver.py +0 -0
  24. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/kmeans_shard.py +0 -0
  25. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/sgd_trainer.py +0 -0
  26. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/sharded_kmeans.py +0 -0
  27. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/sklearn_trial.py +0 -0
  28. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/__init__.py +0 -0
  29. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/artifacts/__init__.py +0 -0
  30. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/artifacts/store.py +0 -0
  31. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/backends/__init__.py +0 -0
  32. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/backends/base.py +0 -0
  33. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/backends/kuberay.py +0 -0
  34. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/checkpoint/__init__.py +0 -0
  35. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/checkpoint/catalog.py +0 -0
  36. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/checkpoint/local.py +0 -0
  37. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/checkpoint/store.py +0 -0
  38. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/integrations/__init__.py +0 -0
  39. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/integrations/huggingface.py +0 -0
  40. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/integrations/pytorch.py +0 -0
  41. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/integrations/sklearn.py +0 -0
  42. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/launchers/__init__.py +0 -0
  43. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/launchers/local.py +0 -0
  44. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/leases/__init__.py +0 -0
  45. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/leases/manager.py +0 -0
  46. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/leases/sqlite_store.py +0 -0
  47. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/leases/store.py +0 -0
  48. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/monitor/__init__.py +0 -0
  49. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/monitor/sampler.py +0 -0
  50. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/__init__.py +0 -0
  51. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/candidates.py +0 -0
  52. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/catalog.py +0 -0
  53. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/comm.py +0 -0
  54. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/explain.py +0 -0
  55. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/memory.py +0 -0
  56. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/resolve.py +0 -0
  57. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/selector.py +0 -0
  58. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/timecost.py +0 -0
  59. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/profiling/__init__.py +0 -0
  60. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/protocol/__init__.py +0 -0
  61. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/protocol/plan_v1alpha1.py +0 -0
  62. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/providers/__init__.py +0 -0
  63. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/py.typed +0 -0
  64. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recipes/__init__.py +0 -0
  65. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recovery/__init__.py +0 -0
  66. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recovery/policy.py +0 -0
  67. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recovery/signals.py +0 -0
  68. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recovery/taxonomy.py +0 -0
  69. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/sdk.py +0 -0
  70. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/__init__.py +0 -0
  71. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/app.py +0 -0
  72. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/auth.py +0 -0
  73. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/checkpoints.py +0 -0
  74. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/cli.py +0 -0
  75. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/dashboard.py +0 -0
  76. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/ledger.py +0 -0
  77. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/strategies/__init__.py +0 -0
  78. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/strategies/command.py +0 -0
  79. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/torch/__init__.py +0 -0
  80. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/__init__.py +0 -0
  81. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/benchmarks.html +0 -0
  82. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
  83. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/get-started.html +0 -0
  84. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
  85. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
  86. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
  87. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
  88. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
  89. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/index.html +0 -0
  90. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/cli.html +0 -0
  91. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
  92. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
  93. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
  94. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/search-index.json +0 -0
  95. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
  96. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
  97. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
  98. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/flowmap.py +0 -0
  99. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/page.py +0 -0
  100. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/server.py +0 -0
  101. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/state.py +0 -0
  102. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/workloads/__init__.py +0 -0
  103. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/workloads/command.py +0 -0
  104. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime.egg-info/dependency_links.txt +0 -0
  105. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime.egg-info/entry_points.txt +0 -0
  106. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime.egg-info/requires.txt +0 -0
  107. {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime.egg-info/top_level.txt +0 -0
  108. {flashruntime-0.4.0 → flashruntime-0.4.2}/setup.cfg +0 -0
  109. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_auto_recovery.py +0 -0
  110. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_benchmarks.py +0 -0
  111. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_checkpoint.py +0 -0
  112. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_checkpoint_local.py +0 -0
  113. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_cli_submit.py +0 -0
  114. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_documentation.py +0 -0
  115. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_examples_e2e.py +0 -0
  116. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_fedavg_convergence.py +0 -0
  117. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_fedavg_worker.py +0 -0
  118. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_gpu_e2e.py +0 -0
  119. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_integrations.py +0 -0
  120. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_interfaces.py +0 -0
  121. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_kmeans_leases.py +0 -0
  122. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_kmeans_workload.py +0 -0
  123. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_kuberay_backend.py +0 -0
  124. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_launcher_local.py +0 -0
  125. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_leases.py +0 -0
  126. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_leases_scope.py +0 -0
  127. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_leases_sqlite.py +0 -0
  128. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_monitor_sampler.py +0 -0
  129. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_placement_local_data.py +0 -0
  130. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_planner.py +0 -0
  131. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_protocol_local_datasets.py +0 -0
  132. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_protocol_v1alpha1.py +0 -0
  133. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_recipe_local_inputs.py +0 -0
  134. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_recipes_command.py +0 -0
  135. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_recovery.py +0 -0
  136. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_run_json.py +0 -0
  137. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_scheduler_isolation.py +0 -0
  138. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_sdk_submit.py +0 -0
  139. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_auth.py +0 -0
  140. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_auth_startup.py +0 -0
  141. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_checkpoints.py +0 -0
  142. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_command_recipe.py +0 -0
  143. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_delegation.py +0 -0
  144. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_fedavg.py +0 -0
  145. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_modea.py +0 -0
  146. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_write_scope.py +0 -0
  147. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_sgd_trainer.py +0 -0
  148. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_strategy_command.py +0 -0
  149. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_torch_helper.py +0 -0
  150. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_viewer_flowmap.py +0 -0
  151. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_viewer_page.py +0 -0
  152. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_viewer_server.py +0 -0
  153. {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_viewer_state.py +0 -0
  154. {flashruntime-0.4.0 → flashruntime-0.4.2}/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.0
3
+ Version: 0.4.2
4
4
  Summary: Open fault-tolerant distributed ML runtime: strategy planning, job protocol, leases, checkpointing, and recovery across heterogeneous compute.
5
5
  Author: Zolli Labs
6
6
  License-Expression: Apache-2.0
@@ -29,7 +29,7 @@ from typing import Any, Callable, Protocol, Sequence, TypedDict
29
29
 
30
30
  from flashml_workloads.fedavg_weights import (
31
31
  apply_delta,
32
- reduce_deltas,
32
+ reduce_deltas_with_report,
33
33
  require_finite,
34
34
  )
35
35
 
@@ -73,6 +73,26 @@ class RoundResult(TypedDict):
73
73
  mean_loss: float
74
74
  job_id: str
75
75
 
76
+ #: The round's contributions whose influence the aggregation capped, as
77
+ #: ``{"task_id", "norm", "cap", "scale"}`` — empty on an honest round,
78
+ #: which is every round unless somebody tried.
79
+ #:
80
+ #: ``task_id``, not a node id, because a task id is what this driver
81
+ #: actually holds. `fedavg_weights` reports positional indices; the round
82
+ #: knows which task each position came from, and a task is what a
83
+ #: coordinator leases. Resolving that to a MACHINE means deciding which
84
+ #: attempts count as accepted work, and that judgement already lives in
85
+ #: exactly one place — the cloud's round recorder, which reads the
86
+ #: coordinator's task view for provenance and joins on this task id.
87
+ #: Making a second copy of it here is how the two disagree.
88
+ #:
89
+ #: Recorded, never enforced. Nothing is quarantined, no credit withheld,
90
+ #: no lease refused: with a fleet this small a false positive costs a
91
+ #: volunteer their machine while a false negative costs one undeserved
92
+ #: credit, and that asymmetry only points one way. The row exists so the
93
+ #: owner can look.
94
+ clipped: list[dict]
95
+
76
96
 
77
97
  class RoundPlan(TypedDict):
78
98
  """What one round is: the job body to submit, and the task ids it will
@@ -535,7 +555,17 @@ def run_fedavg(
535
555
  # download. Anything committing from here on is discarded by
536
556
  # construction: we never re-read this job after aggregating.
537
557
  collected = _fetch(coord, keys)
538
- reduced = reduce_deltas([(d, n) for d, n, _ in collected])
558
+ reduced, clip_events = reduce_deltas_with_report(
559
+ [(d, n) for d, n, _ in collected])
560
+ # `_fetch` preserves `keys`' order one-for-one, and every key in
561
+ # `keys` came out of the expected-key map, so a clip event's
562
+ # positional index resolves back to the exact task that sent it.
563
+ # Positional, NOT a shard number: a round aggregates on a quorum, so
564
+ # with shard-001 absent the third committer sits at position 2, and
565
+ # re-deriving `shard-{index:03d}` would name an honest volunteer.
566
+ by_key = _expected_metrics_keys(job_id, task_ids)
567
+ clipped = [{"task_id": by_key[keys[e.index]], "norm": e.norm,
568
+ "cap": e.cap, "scale": e.scale} for e in clip_events]
539
569
  # No weights yet (`initial_weights={}` and nothing aggregated): the
540
570
  # round's workers were handed nothing, so what they reported as
541
571
  # "the change from what you were given" is the weights themselves.
@@ -561,6 +591,7 @@ def run_fedavg(
561
591
  "participants": len(collected),
562
592
  "mean_loss": sum(loss * n for _, n, loss in collected) / total_n,
563
593
  "job_id": job_id,
594
+ "clipped": clipped,
564
595
  }
565
596
  history.append(result)
566
597
  if on_round is not None:
@@ -15,18 +15,48 @@ container.
15
15
  from __future__ import annotations
16
16
 
17
17
  import math
18
+ from typing import NamedTuple
18
19
 
19
20
  __all__ = [
21
+ "CLIP_FACTOR",
22
+ "ClipEvent",
20
23
  "NonFiniteWeights",
21
24
  "WeightShapeMismatch",
22
25
  "apply_delta",
23
26
  "decode",
24
27
  "encode",
25
28
  "reduce_deltas",
29
+ "reduce_deltas_with_report",
26
30
  "require_finite",
27
31
  "subtract",
28
32
  ]
29
33
 
34
+ #: How many times the round's MEDIAN contribution norm a single contribution
35
+ #: may be before it is scaled back to that bound.
36
+ #:
37
+ #: 3.0, and the number is load-bearing. The governing property of the cap is
38
+ #: that an honest round is bit-identical to a round with no cap at all:
39
+ #: honest per-shard variation sits well inside 3x the median, so nothing
40
+ #: fires and the arithmetic below is untouched. A factor of 1.0 would clip
41
+ #: roughly half of every honest round and silently alter results that are
42
+ #: correct today — a behaviour change wearing a safety net's clothes.
43
+ CLIP_FACTOR: float = 3.0
44
+
45
+
46
+ class ClipEvent(NamedTuple):
47
+ """One contribution that exceeded the round's cap, and by how much.
48
+
49
+ `index` is POSITIONAL into the `contributions` list the caller passed,
50
+ not a node id: this module is pure stdlib and knows nothing about
51
+ machines. Attribution is the driver's job — it holds the per-task
52
+ provenance and maps an index back to whoever sent it.
53
+ """
54
+
55
+ index: int
56
+ norm: float
57
+ cap: float
58
+ scale: float
59
+
30
60
 
31
61
  class WeightShapeMismatch(ValueError):
32
62
  """Two weight blobs do not describe the same parameter set.
@@ -138,13 +168,102 @@ def apply_delta(base: dict, delta: dict, scale: float = 1.0) -> dict:
138
168
  }, "apply_delta")
139
169
 
140
170
 
141
- def reduce_deltas(contributions: list[tuple[dict, int]]) -> dict:
171
+ def _l2_norm(blob: dict) -> float:
172
+ """L2 norm of a delta, flattened across every parameter.
173
+
174
+ Only ever called on a blob `require_finite` has already accepted: the
175
+ multiplication below is a `TypeError` on the `None` a volunteer can put
176
+ in `data`, and a NaN anywhere would make the norm NaN, `norm > cap`
177
+ False, and the contribution sail through unscaled.
178
+ """
179
+ return math.sqrt(sum(v * v for p in blob.values() for v in p["data"]))
180
+
181
+
182
+ def _median(values: list[float]) -> float:
183
+ """Median, with the even-length case spelled out: the mean of the two
184
+ middles.
185
+
186
+ Which is precisely why the cap is weak at two contributions — the
187
+ median of two values is their mean, and an attacker moves a mean
188
+ directly. Robust statistics need a majority to be honest, and with
189
+ `min_participants = 2` there is no majority to have. Documented, not
190
+ papered over: this does not fail closed at that quorum and must not be
191
+ described as protection there.
192
+ """
193
+ ordered = sorted(values)
194
+ mid = len(ordered) // 2
195
+ if len(ordered) % 2:
196
+ return ordered[mid]
197
+ return (ordered[mid - 1] + ordered[mid]) / 2
198
+
199
+
200
+ def _scale_blob(blob: dict, scale: float) -> dict:
201
+ return {name: {"shape": list(p["shape"]),
202
+ "data": [scale * v for v in p["data"]]}
203
+ for name, p in blob.items()}
204
+
205
+
206
+ def reduce_deltas(contributions: list[tuple[dict, int]],
207
+ *, clip_factor: float = CLIP_FACTOR) -> dict:
142
208
  """Sample-weighted mean of per-worker deltas (FedAvg).
143
209
 
144
210
  Weighting by sample count, not by worker, is what keeps the result
145
211
  equal to centralized training on the union of the shards when the
146
212
  shards are unequal — which they always are once machines differ.
213
+
214
+ A thin wrapper over `reduce_deltas_with_report`, kept because this name
215
+ has one production caller and 30+ tests pinning it. The clip report is
216
+ additive; callers that want it ask for it by name.
147
217
  """
218
+ return reduce_deltas_with_report(contributions, clip_factor=clip_factor)[0]
219
+
220
+
221
+ def reduce_deltas_with_report(
222
+ contributions: list[tuple[dict, int]],
223
+ *,
224
+ clip_factor: float = CLIP_FACTOR,
225
+ ) -> tuple[dict, list[ClipEvent]]:
226
+ """`reduce_deltas`, plus the list of contributions the cap bound.
227
+
228
+ Everything MALFORMED is rejected below — non-positive, non-finite and
229
+ non-integer sample counts, mismatched shapes, NaN/Inf weights. What none
230
+ of those guards catch is a contribution that is perfectly well-formed
231
+ and adversarial: `delta = 1e6`, `n = 500`. Every check passes and the
232
+ sample-weighted mean moves the model by whatever the sender chose.
233
+
234
+ So, after validation and before the mean, each contribution's L2 norm is
235
+ compared against `C = median(norms) * clip_factor` and anything above it
236
+ is scaled to `C`. Median-anchored rather than a fixed constant because
237
+ the right magnitude depends on the model, the learning rate and the
238
+ round number, none of which this module knows — and because with a
239
+ majority of honest contributors the median is an honest value, which an
240
+ attacker-chosen mean is not.
241
+
242
+ Bounds MAGNITUDE, not direction. A small, consistently-biased delta
243
+ every round is unaffected, a node returning zeros still earns credit,
244
+ and a colluding majority defeats it by construction. It is a cap on how
245
+ far one contributor can move the model, not result verification.
246
+
247
+ Nothing is enforced here beyond the scaling: the events are returned so
248
+ the caller can record them. Revocation is a human decision.
249
+ """
250
+ # First, because it is the function's own configuration rather than
251
+ # untrusted input, and because the failure mode of getting it wrong is
252
+ # the worst one available: a non-positive or non-finite factor would
253
+ # disable the cap silently, the round would still reduce, and it would
254
+ # still report an empty clip list that an operator reads as "nobody
255
+ # tried". `clip_factor=0` would additionally zero every contribution.
256
+ try:
257
+ usable = math.isfinite(clip_factor) and clip_factor > 0
258
+ except TypeError: # not a number at all
259
+ usable = False
260
+ if not usable:
261
+ raise ValueError(
262
+ f"reduce_deltas: clip_factor must be a finite number > 0, got "
263
+ f"{clip_factor!r}; a non-positive or non-finite value would "
264
+ "silently disable the influence cap rather than widening it"
265
+ )
266
+
148
267
  if not contributions:
149
268
  raise ValueError("reduce_deltas: no contributions")
150
269
  total = sum(n for _, n in contributions)
@@ -212,12 +331,38 @@ def reduce_deltas(contributions: list[tuple[dict, int]]) -> dict:
212
331
  _require_same_params(first, blob)
213
332
  require_finite(blob, f"reduce_deltas: contribution {i}")
214
333
 
334
+ # -- bounded influence, and ONLY here: after every guard above, because
335
+ # a malformed contribution must raise its own error rather than be
336
+ # quietly scaled into something plausible. Clipping caps a delta's
337
+ # magnitude and does nothing at all about a negative sample weight.
338
+ #
339
+ # `reduced` deliberately reuses the caller's own blob objects for every
340
+ # contribution that is not clipped, so an honest round accumulates the
341
+ # exact same float objects in the exact same order as it did before this
342
+ # existed. Byte-identical is the governing property; rebuilding every
343
+ # blob "harmlessly" would be the easiest way to lose it.
344
+ # Computed once, not once per use: deltas are megabytes, and this walks
345
+ # every weight in every contribution.
346
+ norms = [_l2_norm(blob) for blob, _ in contributions]
347
+ cap = _median(norms) * clip_factor
348
+ clipped: list[tuple[dict, int]] = list(contributions)
349
+ events: list[ClipEvent] = []
350
+ for i, ((blob, n), norm) in enumerate(zip(contributions, norms)):
351
+ # Strict `>` against a cap that is never negative — so a zero-norm
352
+ # contribution (a converged shard, or a lazy node returning zeros)
353
+ # is never the one being scaled, and `cap / norm` never divides by
354
+ # zero. An all-zero round puts the cap at 0.0 too and clips nothing.
355
+ if norm > cap:
356
+ scale = cap / norm
357
+ clipped[i] = (_scale_blob(blob, scale), n)
358
+ events.append(ClipEvent(index=i, norm=norm, cap=cap, scale=scale))
359
+
215
360
  out: dict = {}
216
361
  for name in first:
217
362
  acc = [0.0] * len(first[name]["data"])
218
- for blob, n in contributions:
363
+ for blob, n in clipped:
219
364
  w = n / total
220
365
  for i, v in enumerate(blob[name]["data"]):
221
366
  acc[i] += w * v
222
367
  out[name] = {"shape": list(first[name]["shape"]), "data": acc}
223
- return out
368
+ return out, events
@@ -78,6 +78,13 @@ class ResourcesSpec(BaseModel):
78
78
  maximumWorkers: int = Field(ge=1, default=3)
79
79
  cpuPerTask: float = Field(gt=0, default=1)
80
80
  memoryPerTask: str = "512Mi"
81
+ #: GPUs each task needs. 0 — the default, and every job that exists
82
+ #: today — means "no GPU required" and places anywhere. A non-zero value
83
+ #: is a hardware *requirement*, enforced fail-closed at placement
84
+ #: (`scheduler.IsolationAwarePlacement`): a CUDA task on a CPU-only host
85
+ #: does not politely requeue, it crashes or silently runs on the CPU two
86
+ #: orders of magnitude slower while reporting success.
87
+ gpuPerTask: int = Field(ge=0, default=0)
81
88
 
82
89
  @field_validator("maximumWorkers")
83
90
  @classmethod
@@ -269,10 +276,39 @@ class NodeEnvironment(str, Enum):
269
276
  EDGE = "edge"
270
277
 
271
278
 
279
+ class GpuInfo(BaseModel):
280
+ """One GPU as the host's driver reports it.
281
+
282
+ Every field but `index` is optional: a probe that cannot read a value says
283
+ nothing rather than guessing, so a partial reading is still reportable and
284
+ an unreadable one is still `[]` rather than a fabricated device.
285
+
286
+ Typed now although placement matches only on COUNT, because the wire
287
+ format is the expensive thing to change later — these agents run on
288
+ machines we cannot reach, and a field added to an existing model is cheap
289
+ where turning a `dict` into a model is not. `memory_total_mb` and
290
+ `compute_capability` are collected from the start so the data is already
291
+ flowing when matching rules arrive (spec §10.1).
292
+
293
+ No `schema_version`: like `CheckpointPart`, this is a nested element of a
294
+ versioned message (`NodeRegistration`), not a wire message of its own.
295
+ """
296
+
297
+ index: int
298
+ name: str = ""
299
+ memory_total_mb: int | None = None
300
+ driver_version: str = ""
301
+ compute_capability: str = ""
302
+
303
+
272
304
  class NodeCapabilities(BaseModel):
273
305
  cpu_cores: float | None = None
274
306
  memory_bytes: int | None = None
275
- gpus: list[dict[str, Any]] = Field(default_factory=list)
307
+ #: Devices this host's driver reports, one entry per GPU. Empty is the
308
+ #: honest answer for a host with no driver, no `nvidia-smi`, or output
309
+ #: this runtime cannot parse — never a guess. Placement reads the LENGTH
310
+ #: of this list and nothing else in v1.
311
+ gpus: list[GpuInfo] = Field(default_factory=list)
276
312
  os: str = ""
277
313
  architecture: str = ""
278
314
 
@@ -391,6 +427,60 @@ class TaskAttempt(BaseModel):
391
427
  accepted: bool = False
392
428
 
393
429
 
430
+ class ExecutionEvidence(BaseModel):
431
+ """What the agent says about the run it is committing. **Evidence, never
432
+ proof** — the agent is the untrusted party, so every field here is
433
+ something a liar could fabricate. Its value is that a liar must now keep
434
+ two stories straight: these numbers have to stay consistent with the
435
+ elapsed time the *coordinator* measured between claim and commit, which
436
+ the agent does not control.
437
+
438
+ Optional on `CompleteRequest`, and it must stay optional — see the note
439
+ there.
440
+
441
+ NONE MEANS "NOT MEASURED". ZERO MEANS "MEASURED, AND IT WAS ZERO". These
442
+ are different facts and must never collapse onto one value: 0% GPU on a
443
+ task that asked for a GPU is the strongest single signal this block
444
+ carries, and a host with no sampler at all is no signal whatsoever. An
445
+ agent that cannot read a value sends `None`; a fabricated 0.0 turns "we
446
+ could not look" into "this node did nothing" and gets an honest
447
+ volunteer flagged. `image_digest` uses `""` for the same purpose, matching
448
+ `GpuInfo`'s string fields.
449
+
450
+ DELIBERATELY UNCONSTRAINED. No `ge=0`, no upper bound, no format check on
451
+ the digest. A validator here would let an agent's own self-report 422 the
452
+ agent's own work — inverting a design in which nothing is ever enforced
453
+ and everything is at most flagged. An implausible reading is worth more
454
+ recorded than refused, and refusing it mostly teaches a liar which values
455
+ pass.
456
+
457
+ Fields:
458
+ - `wall_seconds` — the agent's own clock around the run, excluding input
459
+ download and output upload. Cross-checked against coordinator-observed
460
+ elapsed, never trusted alone.
461
+ - `cpu_percent_mean` / `gpu_util_percent_mean` — mean utilisation over
462
+ the run, sampled by the agent. HOST-WIDE, not task-scoped: a volunteer
463
+ machine has other things running on it, so a high reading is weak
464
+ evidence and a *low* one is the interesting direction.
465
+ - `image_digest` — which image bytes actually executed, as the host
466
+ resolved them. `""` for a tier that ran no container at all.
467
+ - `exit_code` — the process/container exit status. On the reference agent
468
+ this is 0 whenever it is present at all, because a non-zero exit fails
469
+ the attempt and never reaches the commit path; it is carried so a
470
+ runner that commits partial results stays expressible, and so absence
471
+ still distinguishes an agent that measured from one that did not.
472
+
473
+ No `schema_version`: like `GpuInfo` and `CheckpointPart`, this is a
474
+ nested element of a wire message, not a wire message of its own.
475
+ """
476
+
477
+ wall_seconds: float | None = None
478
+ cpu_percent_mean: float | None = None
479
+ gpu_util_percent_mean: float | None = None
480
+ image_digest: str = ""
481
+ exit_code: int | None = None
482
+
483
+
394
484
  # ---------------------------------------------------------------------------
395
485
  # Checkpoint manifests (additive, July 2026)
396
486
  #
@@ -160,6 +160,26 @@ class CommandRecipe(WorkloadRecipe):
160
160
  #
161
161
  # `list(...)` so a payload never aliases the caller's spec.
162
162
  payload["local_inputs"] = list(p["local_inputs"])
163
+ gpus = spec.spec.resources.gpuPerTask
164
+ if gpus:
165
+ # The GPU requirement is the ONLY payload key sourced from
166
+ # `spec.spec.resources` rather than `workload.parameters` —
167
+ # a branch of the spec nothing else in this method reads.
168
+ # That makes it the easiest forward in the file to leave out
169
+ # and the hardest to notice missing: there is no unrecognised
170
+ # parameter sitting in `p` to go looking for.
171
+ #
172
+ # Dropping it does NOT fail closed. `IsolationAwarePlacement`
173
+ # reads `task.payload["gpus"]`; with the key absent it sees a
174
+ # task requiring nothing, places it on any node, and a CUDA
175
+ # job lands on a CPU-only volunteer. Both ends of this hop
176
+ # have tests that pass while it is broken, because each
177
+ # constructs the payload directly.
178
+ #
179
+ # Absent stays absent, never 0 — the no-GPU path must keep
180
+ # exercising the key-missing branch, as unpack_inputs and
181
+ # local_inputs do.
182
+ payload["gpus"] = int(gpus)
163
183
  tasks.append(
164
184
  TaskSpec(
165
185
  task_id=task_id,
@@ -172,6 +172,88 @@ class IsolationAwarePlacement(PlacementPolicy):
172
172
  is not in its map) — failing closed here makes it fail before anything
173
173
  touches the data, and before an attempt is burned.
174
174
 
175
+ A fifth gate applies to tasks whose payload asks for `gpus: N`: the
176
+ claiming node's `capabilities.gpus` must be a list of at least N entries.
177
+ It takes the argv/local-data polarity — **fail closed** — and deliberately
178
+ NOT the module gate's fail-open one, even though "the node cannot run
179
+ this" sounds like the same availability concern:
180
+
181
+ - A misplaced module task wastes retry attempts and nothing else. A CUDA
182
+ job on a CPU-only box does not politely fail and requeue. It either
183
+ crashes on `torch.cuda.is_available()` or, worse, silently falls back
184
+ to the CPU and runs two orders of magnitude slower while reporting
185
+ success. The second outcome is not a failure anything here can detect;
186
+ it is a bill.
187
+ - The capability must be a genuine *list*, because its LENGTH is the
188
+ whole matching rule in v1. Absent, `None`, a bare string, a dict, or a
189
+ bare `int` count as NO GPUs. The bare `int` deserves naming: `1` reads
190
+ like "one GPU" and is exactly what a hand-written node view would put
191
+ there, but accepting it would mean a second, looser matching rule
192
+ beside the one `NodeCapabilities.gpus` actually feeds.
193
+ - `capabilities` itself may be absent or type-confused; that is read as
194
+ no GPUs rather than allowed to raise. `(node.get("capabilities") or
195
+ {}).get(...)` is NOT sufficient for this — a string capabilities value
196
+ has no `.get` and would crash the predicate.
197
+ - The requirement itself must be a non-negative `int`. Anything else
198
+ (`"1"`, `-1`, `1.5`, a list) makes the task ineligible everywhere
199
+ rather than crashing, exactly as a non-list `local_inputs` does.
200
+ **`bool` is a subclass of `int`**, so `True` is excluded explicitly: a
201
+ `gpus: true` typo must not silently mean "one GPU" and place real work.
202
+ - `gpus: 0` requires nothing and runs anywhere, exactly like tier
203
+ `standard` and an empty `local_inputs`.
204
+
205
+ The gate is ONE-DIRECTIONAL. A node with GPUs still receives CPU work;
206
+ reserving GPU hosts for GPU jobs is a scheduling optimisation and a
207
+ separate decision, and making it a gate here would idle the scarcest
208
+ hardware on the network. `allowFallback` does not waive this gate either
209
+ — hardware either exists on a host or it does not, and the submitter's
210
+ isolation posture has nothing to say about it.
211
+
212
+ A sixth gate applies to tasks whose payload lists `exclude_nodes`: the
213
+ claiming node must not be named there. It is the one runtime change
214
+ upfront redundant assignment cannot be built without — a verification
215
+ twin carries the same payload as its original and must land on a
216
+ DIFFERENT machine, and nothing here could previously say "anywhere but
217
+ there". With a fleet of two, the twin otherwise lands on the same node
218
+ half the time, verifying nothing at double the cost.
219
+
220
+ **Fail closed**, taking the argv/local-data/gpu polarity and not the
221
+ module gate's, and the asymmetry is the whole argument:
222
+
223
+ - A task excluded from everywhere simply never runs, and that announces
224
+ itself — the queue does not drain and the redundancy slice records
225
+ `unknown`, which it is required never to render as `pass`.
226
+ - A twin misplaced onto the excluded node produces a MATCH, which is
227
+ recorded as agreement, which reads as verified. That failure is
228
+ invisible after the fact and cannot be distinguished from a real
229
+ verification by anything downstream. An unplaceable task costs a task;
230
+ a fake verification costs the reason the feature exists.
231
+
232
+ - The requirement must be a genuine *list* of names. A bare string is
233
+ refused even when it looks right, for the reason `local_inputs` states
234
+ at length: `"node-a" in "node-alpha"` is True, so a string exclusion
235
+ would quietly refuse every host whose name contains another's.
236
+ - Every member must be a `str`. A `None`, an `int`, or a nested object
237
+ means the exclusion was built wrong — and the node it meant to name is
238
+ exactly the one a plain membership test would then let through. The
239
+ whole task fails closed rather than the one member being skipped.
240
+ - An EMPTY list excludes nobody and runs anywhere, like `gpus: 0` and an
241
+ empty `local_inputs`. It is what the first member of a pair carries:
242
+ dispatched before anyone has claimed the other, it has nobody to
243
+ exclude yet.
244
+ - The node's own identity must be readable: a non-empty `str` `node_id`.
245
+ A view that cannot answer "are you the node we must avoid?" is refused,
246
+ because "we could not tell" resolving to "go ahead" is the same failure
247
+ the gate exists to prevent, reached from the other side. Scoped to
248
+ tasks that actually exclude something — an absent or empty exclusion
249
+ asks no question. It costs nothing real: every node view the claim
250
+ endpoint builds carries the node_id it just authenticated.
251
+
252
+ `allowFallback` does not waive this one either. It waives the sandbox
253
+ tier and nothing else; a submitter has no standing to say which machine
254
+ already holds the other half of a verification pair — and the point of
255
+ the pair is that the node cannot tell it is in one.
256
+
175
257
  Everything genuinely standard keeps the fail-open placement default."""
176
258
 
177
259
  def eligible(self, task: TaskSpec, node: NodeView) -> bool:
@@ -199,6 +281,47 @@ class IsolationAwarePlacement(PlacementPolicy):
199
281
  return False # absent/None/type-confused capability ⇒ not capable
200
282
  if any(name not in advertised for name in local_inputs):
201
283
  return False
284
+ # Fail-closed like the argv and local-data gates, and checked before
285
+ # the allowFallback waiver below for the same reason: a submitter
286
+ # cannot waive their way onto hardware a host does not have.
287
+ required_gpus = task.payload.get("gpus")
288
+ if required_gpus is not None:
289
+ # `bool` is a subclass of `int`: without the explicit exclusion,
290
+ # a `gpus: true` typo would read as "1 GPU" and place real work.
291
+ if (
292
+ not isinstance(required_gpus, int)
293
+ or isinstance(required_gpus, bool)
294
+ or required_gpus < 0
295
+ ):
296
+ return False # type-confused requirement ⇒ fail closed, no crash
297
+ if required_gpus > 0:
298
+ capabilities = node.get("capabilities")
299
+ # isinstance, not `or {}` — a string capabilities value has no
300
+ # `.get` and must fail closed rather than crash the predicate.
301
+ advertised = (
302
+ capabilities.get("gpus") if isinstance(capabilities, dict) else None
303
+ )
304
+ if not isinstance(advertised, list) or len(advertised) < required_gpus:
305
+ return False # absent/short/type-confused ⇒ not capable
306
+ # Fail-closed like the gates above, and checked before the
307
+ # allowFallback waiver for the same reason: a submitter cannot waive
308
+ # their way onto the machine already running the other half of their
309
+ # verification pair — and is not supposed to know there is one.
310
+ excluded = task.payload.get("exclude_nodes")
311
+ if excluded is not None:
312
+ if not isinstance(excluded, list):
313
+ return False # type-confused requirement ⇒ fail closed, no crash
314
+ if excluded:
315
+ if not all(isinstance(name, str) for name in excluded):
316
+ # A non-name member means this list was built wrong, and
317
+ # the node it meant to exclude is precisely the one a
318
+ # membership test would now let through.
319
+ return False
320
+ node_id = node.get("node_id")
321
+ if not isinstance(node_id, str) or not node_id:
322
+ return False # cannot answer "is this you?" ⇒ do not risk it
323
+ if node_id in excluded:
324
+ return False
202
325
  isolation = task.payload.get("isolation")
203
326
  if isolation is None:
204
327
  return True # no isolation payload ⇒ standard, runs anywhere
@@ -36,6 +36,7 @@ import flashruntime.recipes.command # noqa: F401 — registers the "command" re
36
36
  from flashruntime.leases import LeaseManager
37
37
  from flashruntime.protocol.v1alpha1 import (
38
38
  ArtifactRecord,
39
+ ExecutionEvidence,
39
40
  JobSpec,
40
41
  NodeHeartbeat,
41
42
  NodeRegistration,
@@ -350,6 +351,20 @@ class ClaimRequest(BaseModel):
350
351
 
351
352
  class CompleteRequest(BaseModel):
352
353
  output_sha256: str
354
+ #: What the agent measured about the run it is committing. **Optional,
355
+ #: and it must stay optional.** Every agent deployed today predates this
356
+ #: field; making it required would 422 every completion in the fleet the
357
+ #: moment this coordinator shipped, and the fleet is not reachable to
358
+ #: upgrade first. Same fail-safe polarity as `NodeRegistration
359
+ #: .module_capable` and for the same reason — availability, not security:
360
+ #: an absent block costs a verification signal, a required one costs
361
+ #: every task.
362
+ #:
363
+ #: This coordinator ACCEPTS AND IGNORES it. It keeps no verifications
364
+ #: ledger, and inventing somewhere to put this would put the runtime in
365
+ #: the business of judging its own volunteers; the cloud API reads it and
366
+ #: records a verdict. Absent evidence is `unknown` there — never `pass`.
367
+ evidence: ExecutionEvidence | None = None
353
368
 
354
369
 
355
370
  class FailRequest(BaseModel):
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: flashruntime
3
- Version: 0.4.0
3
+ Version: 0.4.2
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,10 +118,15 @@ 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_exclusion.py
122
+ tests/test_placement_gpu.py
121
123
  tests/test_placement_local_data.py
122
124
  tests/test_planner.py
125
+ tests/test_protocol_evidence.py
126
+ tests/test_protocol_gpu.py
123
127
  tests/test_protocol_local_datasets.py
124
128
  tests/test_protocol_v1alpha1.py
129
+ tests/test_recipe_gpu.py
125
130
  tests/test_recipe_local_inputs.py
126
131
  tests/test_recipes_command.py
127
132
  tests/test_recovery.py
@@ -5,12 +5,36 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "flashruntime"
8
- # 0.4.0 adds NodeRegistration.local_datasetsa 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"
8
+ # 0.4.2 adds ExecutionEvidence and CompleteRequest.evidencethe agent's
9
+ # self-report at commit time. Additive and OPTIONAL on the wire, so the
10
+ # compatibility range does not move and an agent that predates it keeps
11
+ # committing work; the bump is the same rule as 0.4.1's, applied to a
12
+ # wire-visible change even though nothing breaks.
13
+ #
14
+ # flashnode’s floor moves to >=0.4.2,<0.5 in the same change, for the same
15
+ # reason it moved for GpuInfo: `executor/client.py` and `executor/loop.py`
16
+ # import ExecutionEvidence at MODULE scope, so on an older wheel the agent
17
+ # raises ImportError and never registers at all. There is no silent
18
+ # degradation to reason about, which is the one comfort in this shape of
19
+ # floor error.
20
+ #
21
+ # 0.4.1 adds GpuInfo and ResourcesSpec.gpuPerTask. Both are additive wire
22
+ # fields, so the compatibility range does not move — but the PATCH bump is
23
+ # load-bearing, not bookkeeping. The `flashruntime-v0.4.0` tag was cut BEFORE
24
+ # the protocol commit that added them, and this tree went on calling itself
25
+ # 0.4.0 afterwards: one version string named two different protocols, which is
26
+ # exactly the drift the monorepo consolidation removed. Bump on any
27
+ # wire-visible change, even an additive one.
28
+ #
29
+ # flashnode’s floor moves to >=0.4.1,<0.5 in the same change — it imports
30
+ # GpuInfo at module scope, so for the agent this floor is an import-time hard
31
+ # requirement, not a silent-degradation one.
32
+ #
33
+ # 0.4.0 added NodeRegistration.local_datasets — a new wire field, so consumers
34
+ # must pin a range that includes it. (0.3.0 was the first published release;
35
+ # 0.1.0 and 0.2.0 were declared here but never reached PyPI.) Released from
36
+ # the Zolli-Labs/flashml monorepo by pushing a `flashruntime-vX.Y.Z` tag.
37
+ version = "0.4.2"
14
38
  description = "Open fault-tolerant distributed ML runtime: strategy planning, job protocol, leases, checkpointing, and recovery across heterogeneous compute."
15
39
  readme = "README.md"
16
40
  # PEP 639 SPDX expression (string form) — replaces the deprecated