flashruntime 0.4.1__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.1/flashruntime.egg-info → flashruntime-0.4.2}/PKG-INFO +1 -1
  2. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/fedavg_driver.py +33 -2
  3. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/fedavg_weights.py +148 -3
  4. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/protocol/v1alpha1.py +54 -0
  5. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/scheduler/__init__.py +64 -0
  6. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/modea.py +15 -0
  7. {flashruntime-0.4.1 → flashruntime-0.4.2/flashruntime.egg-info}/PKG-INFO +1 -1
  8. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime.egg-info/SOURCES.txt +2 -0
  9. {flashruntime-0.4.1 → flashruntime-0.4.2}/pyproject.toml +14 -1
  10. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_fedavg_driver.py +78 -0
  11. flashruntime-0.4.2/tests/test_fedavg_weights.py +475 -0
  12. flashruntime-0.4.2/tests/test_placement_exclusion.py +296 -0
  13. flashruntime-0.4.2/tests/test_protocol_evidence.py +279 -0
  14. flashruntime-0.4.1/tests/test_fedavg_weights.py +0 -227
  15. {flashruntime-0.4.1 → flashruntime-0.4.2}/LICENSE +0 -0
  16. {flashruntime-0.4.1 → flashruntime-0.4.2}/README.md +0 -0
  17. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/__init__.py +0 -0
  18. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/fedavg_worker.py +0 -0
  19. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/kmeans_driver.py +0 -0
  20. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/kmeans_shard.py +0 -0
  21. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/sgd_trainer.py +0 -0
  22. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/sharded_kmeans.py +0 -0
  23. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/sklearn_trial.py +0 -0
  24. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/__init__.py +0 -0
  25. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/artifacts/__init__.py +0 -0
  26. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/artifacts/store.py +0 -0
  27. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/backends/__init__.py +0 -0
  28. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/backends/base.py +0 -0
  29. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/backends/kuberay.py +0 -0
  30. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/checkpoint/__init__.py +0 -0
  31. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/checkpoint/catalog.py +0 -0
  32. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/checkpoint/local.py +0 -0
  33. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/checkpoint/store.py +0 -0
  34. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/integrations/__init__.py +0 -0
  35. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/integrations/huggingface.py +0 -0
  36. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/integrations/pytorch.py +0 -0
  37. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/integrations/sklearn.py +0 -0
  38. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/launchers/__init__.py +0 -0
  39. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/launchers/local.py +0 -0
  40. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/leases/__init__.py +0 -0
  41. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/leases/manager.py +0 -0
  42. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/leases/sqlite_store.py +0 -0
  43. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/leases/store.py +0 -0
  44. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/monitor/__init__.py +0 -0
  45. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/monitor/sampler.py +0 -0
  46. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/__init__.py +0 -0
  47. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/candidates.py +0 -0
  48. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/catalog.py +0 -0
  49. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/comm.py +0 -0
  50. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/explain.py +0 -0
  51. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/memory.py +0 -0
  52. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/resolve.py +0 -0
  53. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/selector.py +0 -0
  54. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/timecost.py +0 -0
  55. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/profiling/__init__.py +0 -0
  56. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/protocol/__init__.py +0 -0
  57. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/protocol/plan_v1alpha1.py +0 -0
  58. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/providers/__init__.py +0 -0
  59. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/py.typed +0 -0
  60. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recipes/__init__.py +0 -0
  61. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recipes/command.py +0 -0
  62. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recovery/__init__.py +0 -0
  63. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recovery/policy.py +0 -0
  64. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recovery/signals.py +0 -0
  65. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recovery/taxonomy.py +0 -0
  66. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/sdk.py +0 -0
  67. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/__init__.py +0 -0
  68. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/app.py +0 -0
  69. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/auth.py +0 -0
  70. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/checkpoints.py +0 -0
  71. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/cli.py +0 -0
  72. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/dashboard.py +0 -0
  73. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/ledger.py +0 -0
  74. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/strategies/__init__.py +0 -0
  75. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/strategies/command.py +0 -0
  76. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/torch/__init__.py +0 -0
  77. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/__init__.py +0 -0
  78. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/benchmarks.html +0 -0
  79. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
  80. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/get-started.html +0 -0
  81. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
  82. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
  83. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
  84. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
  85. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
  86. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/index.html +0 -0
  87. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/cli.html +0 -0
  88. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
  89. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
  90. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
  91. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/search-index.json +0 -0
  92. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
  93. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
  94. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
  95. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/flowmap.py +0 -0
  96. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/page.py +0 -0
  97. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/server.py +0 -0
  98. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/state.py +0 -0
  99. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/workloads/__init__.py +0 -0
  100. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/workloads/command.py +0 -0
  101. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime.egg-info/dependency_links.txt +0 -0
  102. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime.egg-info/entry_points.txt +0 -0
  103. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime.egg-info/requires.txt +0 -0
  104. {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime.egg-info/top_level.txt +0 -0
  105. {flashruntime-0.4.1 → flashruntime-0.4.2}/setup.cfg +0 -0
  106. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_auto_recovery.py +0 -0
  107. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_benchmarks.py +0 -0
  108. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_checkpoint.py +0 -0
  109. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_checkpoint_local.py +0 -0
  110. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_cli_submit.py +0 -0
  111. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_documentation.py +0 -0
  112. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_examples_e2e.py +0 -0
  113. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_fedavg_convergence.py +0 -0
  114. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_fedavg_worker.py +0 -0
  115. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_gpu_e2e.py +0 -0
  116. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_integrations.py +0 -0
  117. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_interfaces.py +0 -0
  118. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_kmeans_leases.py +0 -0
  119. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_kmeans_workload.py +0 -0
  120. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_kuberay_backend.py +0 -0
  121. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_launcher_local.py +0 -0
  122. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_leases.py +0 -0
  123. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_leases_scope.py +0 -0
  124. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_leases_sqlite.py +0 -0
  125. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_monitor_sampler.py +0 -0
  126. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_placement_gpu.py +0 -0
  127. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_placement_local_data.py +0 -0
  128. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_planner.py +0 -0
  129. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_protocol_gpu.py +0 -0
  130. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_protocol_local_datasets.py +0 -0
  131. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_protocol_v1alpha1.py +0 -0
  132. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_recipe_gpu.py +0 -0
  133. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_recipe_local_inputs.py +0 -0
  134. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_recipes_command.py +0 -0
  135. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_recovery.py +0 -0
  136. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_run_json.py +0 -0
  137. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_scheduler_isolation.py +0 -0
  138. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_sdk_submit.py +0 -0
  139. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_auth.py +0 -0
  140. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_auth_startup.py +0 -0
  141. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_checkpoints.py +0 -0
  142. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_command_recipe.py +0 -0
  143. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_delegation.py +0 -0
  144. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_fedavg.py +0 -0
  145. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_modea.py +0 -0
  146. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_write_scope.py +0 -0
  147. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_sgd_trainer.py +0 -0
  148. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_strategy_command.py +0 -0
  149. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_torch_helper.py +0 -0
  150. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_viewer_flowmap.py +0 -0
  151. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_viewer_page.py +0 -0
  152. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_viewer_server.py +0 -0
  153. {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_viewer_state.py +0 -0
  154. {flashruntime-0.4.1 → 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.1
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
@@ -427,6 +427,60 @@ class TaskAttempt(BaseModel):
427
427
  accepted: bool = False
428
428
 
429
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
+
430
484
  # ---------------------------------------------------------------------------
431
485
  # Checkpoint manifests (additive, July 2026)
432
486
  #
@@ -209,6 +209,51 @@ class IsolationAwarePlacement(PlacementPolicy):
209
209
  — hardware either exists on a host or it does not, and the submitter's
210
210
  isolation posture has nothing to say about it.
211
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
+
212
257
  Everything genuinely standard keeps the fail-open placement default."""
213
258
 
214
259
  def eligible(self, task: TaskSpec, node: NodeView) -> bool:
@@ -258,6 +303,25 @@ class IsolationAwarePlacement(PlacementPolicy):
258
303
  )
259
304
  if not isinstance(advertised, list) or len(advertised) < required_gpus:
260
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
261
325
  isolation = task.payload.get("isolation")
262
326
  if isolation is None:
263
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.1
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,9 +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_exclusion.py
121
122
  tests/test_placement_gpu.py
122
123
  tests/test_placement_local_data.py
123
124
  tests/test_planner.py
125
+ tests/test_protocol_evidence.py
124
126
  tests/test_protocol_gpu.py
125
127
  tests/test_protocol_local_datasets.py
126
128
  tests/test_protocol_v1alpha1.py
@@ -5,6 +5,19 @@ build-backend = "setuptools.build_meta"
5
5
 
6
6
  [project]
7
7
  name = "flashruntime"
8
+ # 0.4.2 adds ExecutionEvidence and CompleteRequest.evidence — the 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
+ #
8
21
  # 0.4.1 adds GpuInfo and ResourcesSpec.gpuPerTask. Both are additive wire
9
22
  # fields, so the compatibility range does not move — but the PATCH bump is
10
23
  # load-bearing, not bookkeeping. The `flashruntime-v0.4.0` tag was cut BEFORE
@@ -21,7 +34,7 @@ name = "flashruntime"
21
34
  # must pin a range that includes it. (0.3.0 was the first published release;
22
35
  # 0.1.0 and 0.2.0 were declared here but never reached PyPI.) Released from
23
36
  # the Zolli-Labs/flashml monorepo by pushing a `flashruntime-vX.Y.Z` tag.
24
- version = "0.4.1"
37
+ version = "0.4.2"
25
38
  description = "Open fault-tolerant distributed ML runtime: strategy planning, job protocol, leases, checkpointing, and recovery across heterogeneous compute."
26
39
  readme = "README.md"
27
40
  # PEP 639 SPDX expression (string form) — replaces the deprecated
@@ -853,3 +853,81 @@ def test_build_round_returning_duplicate_task_ids_is_refused():
853
853
  initial_weights={"w": {"shape": [1], "data": [0.0]}},
854
854
  build_round=build)
855
855
  assert fake.submitted == []
856
+
857
+
858
+ # -- bounded influence: who the cap bound, not just that it bound somebody ---
859
+ #
860
+ # `reduce_deltas_with_report` reports POSITIONAL indices into the list it was
861
+ # handed — it is pure stdlib and knows nothing about machines. The driver is
862
+ # the layer that holds per-task provenance, so turning an index back into
863
+ # something an operator can act on is its job, and getting it wrong names the
864
+ # wrong volunteer, which is worse than naming nobody.
865
+
866
+
867
+ def test_an_honest_round_reports_nothing_clipped():
868
+ fake = FakeCoordinator({0: [(0, 1.0, 10), (1, 1.1, 10), (2, 0.9, 10)]})
869
+ result = run_fedavg(fake, rounds=1, num_shards=3, min_participants=3,
870
+ worker_params=_params(),
871
+ initial_weights={"w": {"shape": [1], "data": [0.0]}})
872
+ assert result["history"][0]["clipped"] == []
873
+
874
+
875
+ def test_a_round_with_one_adversarial_shard_names_that_shard():
876
+ """Norms 1.0 / 1.0 / 1e6: the median is 1.0, the cap 3.0, and shard-002
877
+ enters the average at 3.0 instead of 1000000.0."""
878
+ fake = FakeCoordinator({0: [(0, 1.0, 10), (1, 1.0, 10), (2, 1e6, 10)]})
879
+ result = run_fedavg(fake, rounds=1, num_shards=3, min_participants=3,
880
+ worker_params=_params(),
881
+ initial_weights={"w": {"shape": [1], "data": [0.0]}})
882
+
883
+ clipped = result["history"][0]["clipped"]
884
+ assert len(clipped) == 1
885
+ assert clipped[0]["task_id"] == "shard-002"
886
+ assert clipped[0]["norm"] == 1e6
887
+ assert clipped[0]["cap"] == pytest.approx(3.0)
888
+ assert clipped[0]["scale"] == pytest.approx(3e-6)
889
+
890
+ # And the model actually moved by the bounded amount, not the asked one.
891
+ assert result["weights"]["w"]["data"][0] == pytest.approx((1.0 + 1.0 + 3.0) / 3)
892
+
893
+
894
+ def test_the_clipped_shard_is_identified_positionally_not_by_shard_number():
895
+ """The test that catches the tempting `f"shard-{index:03d}"`.
896
+
897
+ Only shards 0, 2 and 3 of a four-shard round commit — shard-001 is a
898
+ closed laptop, which is the normal case this driver aggregates on a
899
+ QUORUM to tolerate. The adversary is shard-003, but it sits at
900
+ POSITION 2 in the collected list, so an implementation that treats the
901
+ clip index as a shard number blames shard-002: an honest volunteer.
902
+ """
903
+ fake = FakeCoordinator({0: [(0, 1.0, 10), (2, 1.0, 10), (3, 5000.0, 10)]})
904
+ result = run_fedavg(fake, rounds=1, num_shards=4, min_participants=3,
905
+ worker_params=_params(),
906
+ initial_weights={"w": {"shape": [1], "data": [0.0]}})
907
+
908
+ assert [c["task_id"] for c in result["history"][0]["clipped"]] == ["shard-003"]
909
+
910
+
911
+ def test_the_clip_report_reaches_the_on_round_callback():
912
+ """`on_round` is what the cloud writes a `job_rounds` row from, so an
913
+ event that only ever appears in the returned history is an event the
914
+ owner never gets to look at."""
915
+ seen = []
916
+ fake = FakeCoordinator({0: [(0, 1.0, 10), (1, 1.0, 10), (2, 1e6, 10)]})
917
+ run_fedavg(fake, rounds=1, num_shards=3, min_participants=3,
918
+ worker_params=_params(), on_round=seen.append,
919
+ initial_weights={"w": {"shape": [1], "data": [0.0]}})
920
+ assert [c["task_id"] for c in seen[0]["clipped"]] == ["shard-002"]
921
+
922
+
923
+ def test_a_command_shaped_round_names_its_own_task_ids():
924
+ """Task ids come from the round's `RoundPlan`, not from a shard-naming
925
+ rule: a caller compiling the round as a `command` workload gets
926
+ `task-000`, `task-001`, ... and the clip report has to follow it."""
927
+ fake = CommandShapedCoordinator({0: [(0, 1.0, 10), (1, 1.0, 10),
928
+ (2, 1e6, 10)]})
929
+ result = run_fedavg(fake, rounds=1, num_shards=3, min_participants=3,
930
+ worker_params=_params(),
931
+ initial_weights={"w": {"shape": [1], "data": [0.0]}},
932
+ build_round=_command_plan(3))
933
+ assert [c["task_id"] for c in result["history"][0]["clipped"]] == ["task-002"]