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.
- {flashruntime-0.4.0/flashruntime.egg-info → flashruntime-0.4.2}/PKG-INFO +1 -1
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/fedavg_driver.py +33 -2
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/fedavg_weights.py +148 -3
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/protocol/v1alpha1.py +91 -1
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recipes/command.py +20 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/scheduler/__init__.py +123 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/modea.py +15 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2/flashruntime.egg-info}/PKG-INFO +1 -1
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime.egg-info/SOURCES.txt +5 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/pyproject.toml +30 -6
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_fedavg_driver.py +78 -0
- flashruntime-0.4.2/tests/test_fedavg_weights.py +475 -0
- flashruntime-0.4.2/tests/test_placement_exclusion.py +296 -0
- flashruntime-0.4.2/tests/test_placement_gpu.py +301 -0
- flashruntime-0.4.2/tests/test_protocol_evidence.py +279 -0
- flashruntime-0.4.2/tests/test_protocol_gpu.py +152 -0
- flashruntime-0.4.2/tests/test_recipe_gpu.py +129 -0
- flashruntime-0.4.0/tests/test_fedavg_weights.py +0 -227
- {flashruntime-0.4.0 → flashruntime-0.4.2}/LICENSE +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/README.md +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/fedavg_worker.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/kmeans_driver.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/kmeans_shard.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/sgd_trainer.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/sharded_kmeans.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashml_workloads/sklearn_trial.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/artifacts/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/artifacts/store.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/backends/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/backends/base.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/backends/kuberay.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/checkpoint/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/checkpoint/catalog.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/checkpoint/local.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/checkpoint/store.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/integrations/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/integrations/huggingface.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/integrations/pytorch.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/integrations/sklearn.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/launchers/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/launchers/local.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/leases/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/leases/manager.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/leases/sqlite_store.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/leases/store.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/monitor/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/monitor/sampler.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/candidates.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/catalog.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/comm.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/explain.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/memory.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/resolve.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/selector.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/planner/timecost.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/profiling/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/protocol/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/protocol/plan_v1alpha1.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/providers/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/py.typed +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recipes/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recovery/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recovery/policy.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recovery/signals.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/recovery/taxonomy.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/sdk.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/app.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/auth.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/checkpoints.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/cli.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/dashboard.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/service/ledger.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/strategies/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/strategies/command.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/torch/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/benchmarks.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/get-started.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/index.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/cli.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/search-index.json +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/flowmap.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/page.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/server.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/viewer/state.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/workloads/__init__.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime/workloads/command.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime.egg-info/dependency_links.txt +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime.egg-info/entry_points.txt +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime.egg-info/requires.txt +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/flashruntime.egg-info/top_level.txt +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/setup.cfg +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_auto_recovery.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_benchmarks.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_checkpoint.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_checkpoint_local.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_cli_submit.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_documentation.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_examples_e2e.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_fedavg_convergence.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_fedavg_worker.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_gpu_e2e.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_integrations.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_interfaces.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_kmeans_leases.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_kmeans_workload.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_kuberay_backend.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_launcher_local.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_leases.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_leases_scope.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_leases_sqlite.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_monitor_sampler.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_placement_local_data.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_planner.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_protocol_local_datasets.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_protocol_v1alpha1.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_recipe_local_inputs.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_recipes_command.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_recovery.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_run_json.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_scheduler_isolation.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_sdk_submit.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_auth.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_auth_startup.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_checkpoints.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_command_recipe.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_delegation.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_fedavg.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_modea.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_service_write_scope.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_sgd_trainer.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_strategy_command.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_torch_helper.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_viewer_flowmap.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_viewer_page.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_viewer_server.py +0 -0
- {flashruntime-0.4.0 → flashruntime-0.4.2}/tests/test_viewer_state.py +0 -0
- {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.
|
|
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
|
-
|
|
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 =
|
|
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
|
|
171
|
+
def _l2_norm(blob: dict) -> float:
|
|
172
|
+
"""L2 norm of a delta, flattened across every parameter.
|
|
173
|
+
|
|
174
|
+
Only ever called on a blob `require_finite` has already accepted: the
|
|
175
|
+
multiplication below is a `TypeError` on the `None` a volunteer can put
|
|
176
|
+
in `data`, and a NaN anywhere would make the norm NaN, `norm > cap`
|
|
177
|
+
False, and the contribution sail through unscaled.
|
|
178
|
+
"""
|
|
179
|
+
return math.sqrt(sum(v * v for p in blob.values() for v in p["data"]))
|
|
180
|
+
|
|
181
|
+
|
|
182
|
+
def _median(values: list[float]) -> float:
|
|
183
|
+
"""Median, with the even-length case spelled out: the mean of the two
|
|
184
|
+
middles.
|
|
185
|
+
|
|
186
|
+
Which is precisely why the cap is weak at two contributions — the
|
|
187
|
+
median of two values is their mean, and an attacker moves a mean
|
|
188
|
+
directly. Robust statistics need a majority to be honest, and with
|
|
189
|
+
`min_participants = 2` there is no majority to have. Documented, not
|
|
190
|
+
papered over: this does not fail closed at that quorum and must not be
|
|
191
|
+
described as protection there.
|
|
192
|
+
"""
|
|
193
|
+
ordered = sorted(values)
|
|
194
|
+
mid = len(ordered) // 2
|
|
195
|
+
if len(ordered) % 2:
|
|
196
|
+
return ordered[mid]
|
|
197
|
+
return (ordered[mid - 1] + ordered[mid]) / 2
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
def _scale_blob(blob: dict, scale: float) -> dict:
|
|
201
|
+
return {name: {"shape": list(p["shape"]),
|
|
202
|
+
"data": [scale * v for v in p["data"]]}
|
|
203
|
+
for name, p in blob.items()}
|
|
204
|
+
|
|
205
|
+
|
|
206
|
+
def reduce_deltas(contributions: list[tuple[dict, int]],
|
|
207
|
+
*, clip_factor: float = CLIP_FACTOR) -> dict:
|
|
142
208
|
"""Sample-weighted mean of per-worker deltas (FedAvg).
|
|
143
209
|
|
|
144
210
|
Weighting by sample count, not by worker, is what keeps the result
|
|
145
211
|
equal to centralized training on the union of the shards when the
|
|
146
212
|
shards are unequal — which they always are once machines differ.
|
|
213
|
+
|
|
214
|
+
A thin wrapper over `reduce_deltas_with_report`, kept because this name
|
|
215
|
+
has one production caller and 30+ tests pinning it. The clip report is
|
|
216
|
+
additive; callers that want it ask for it by name.
|
|
147
217
|
"""
|
|
218
|
+
return reduce_deltas_with_report(contributions, clip_factor=clip_factor)[0]
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def reduce_deltas_with_report(
|
|
222
|
+
contributions: list[tuple[dict, int]],
|
|
223
|
+
*,
|
|
224
|
+
clip_factor: float = CLIP_FACTOR,
|
|
225
|
+
) -> tuple[dict, list[ClipEvent]]:
|
|
226
|
+
"""`reduce_deltas`, plus the list of contributions the cap bound.
|
|
227
|
+
|
|
228
|
+
Everything MALFORMED is rejected below — non-positive, non-finite and
|
|
229
|
+
non-integer sample counts, mismatched shapes, NaN/Inf weights. What none
|
|
230
|
+
of those guards catch is a contribution that is perfectly well-formed
|
|
231
|
+
and adversarial: `delta = 1e6`, `n = 500`. Every check passes and the
|
|
232
|
+
sample-weighted mean moves the model by whatever the sender chose.
|
|
233
|
+
|
|
234
|
+
So, after validation and before the mean, each contribution's L2 norm is
|
|
235
|
+
compared against `C = median(norms) * clip_factor` and anything above it
|
|
236
|
+
is scaled to `C`. Median-anchored rather than a fixed constant because
|
|
237
|
+
the right magnitude depends on the model, the learning rate and the
|
|
238
|
+
round number, none of which this module knows — and because with a
|
|
239
|
+
majority of honest contributors the median is an honest value, which an
|
|
240
|
+
attacker-chosen mean is not.
|
|
241
|
+
|
|
242
|
+
Bounds MAGNITUDE, not direction. A small, consistently-biased delta
|
|
243
|
+
every round is unaffected, a node returning zeros still earns credit,
|
|
244
|
+
and a colluding majority defeats it by construction. It is a cap on how
|
|
245
|
+
far one contributor can move the model, not result verification.
|
|
246
|
+
|
|
247
|
+
Nothing is enforced here beyond the scaling: the events are returned so
|
|
248
|
+
the caller can record them. Revocation is a human decision.
|
|
249
|
+
"""
|
|
250
|
+
# First, because it is the function's own configuration rather than
|
|
251
|
+
# untrusted input, and because the failure mode of getting it wrong is
|
|
252
|
+
# the worst one available: a non-positive or non-finite factor would
|
|
253
|
+
# disable the cap silently, the round would still reduce, and it would
|
|
254
|
+
# still report an empty clip list that an operator reads as "nobody
|
|
255
|
+
# tried". `clip_factor=0` would additionally zero every contribution.
|
|
256
|
+
try:
|
|
257
|
+
usable = math.isfinite(clip_factor) and clip_factor > 0
|
|
258
|
+
except TypeError: # not a number at all
|
|
259
|
+
usable = False
|
|
260
|
+
if not usable:
|
|
261
|
+
raise ValueError(
|
|
262
|
+
f"reduce_deltas: clip_factor must be a finite number > 0, got "
|
|
263
|
+
f"{clip_factor!r}; a non-positive or non-finite value would "
|
|
264
|
+
"silently disable the influence cap rather than widening it"
|
|
265
|
+
)
|
|
266
|
+
|
|
148
267
|
if not contributions:
|
|
149
268
|
raise ValueError("reduce_deltas: no contributions")
|
|
150
269
|
total = sum(n for _, n in contributions)
|
|
@@ -212,12 +331,38 @@ def reduce_deltas(contributions: list[tuple[dict, int]]) -> dict:
|
|
|
212
331
|
_require_same_params(first, blob)
|
|
213
332
|
require_finite(blob, f"reduce_deltas: contribution {i}")
|
|
214
333
|
|
|
334
|
+
# -- bounded influence, and ONLY here: after every guard above, because
|
|
335
|
+
# a malformed contribution must raise its own error rather than be
|
|
336
|
+
# quietly scaled into something plausible. Clipping caps a delta's
|
|
337
|
+
# magnitude and does nothing at all about a negative sample weight.
|
|
338
|
+
#
|
|
339
|
+
# `reduced` deliberately reuses the caller's own blob objects for every
|
|
340
|
+
# contribution that is not clipped, so an honest round accumulates the
|
|
341
|
+
# exact same float objects in the exact same order as it did before this
|
|
342
|
+
# existed. Byte-identical is the governing property; rebuilding every
|
|
343
|
+
# blob "harmlessly" would be the easiest way to lose it.
|
|
344
|
+
# Computed once, not once per use: deltas are megabytes, and this walks
|
|
345
|
+
# every weight in every contribution.
|
|
346
|
+
norms = [_l2_norm(blob) for blob, _ in contributions]
|
|
347
|
+
cap = _median(norms) * clip_factor
|
|
348
|
+
clipped: list[tuple[dict, int]] = list(contributions)
|
|
349
|
+
events: list[ClipEvent] = []
|
|
350
|
+
for i, ((blob, n), norm) in enumerate(zip(contributions, norms)):
|
|
351
|
+
# Strict `>` against a cap that is never negative — so a zero-norm
|
|
352
|
+
# contribution (a converged shard, or a lazy node returning zeros)
|
|
353
|
+
# is never the one being scaled, and `cap / norm` never divides by
|
|
354
|
+
# zero. An all-zero round puts the cap at 0.0 too and clips nothing.
|
|
355
|
+
if norm > cap:
|
|
356
|
+
scale = cap / norm
|
|
357
|
+
clipped[i] = (_scale_blob(blob, scale), n)
|
|
358
|
+
events.append(ClipEvent(index=i, norm=norm, cap=cap, scale=scale))
|
|
359
|
+
|
|
215
360
|
out: dict = {}
|
|
216
361
|
for name in first:
|
|
217
362
|
acc = [0.0] * len(first[name]["data"])
|
|
218
|
-
for blob, n in
|
|
363
|
+
for blob, n in clipped:
|
|
219
364
|
w = n / total
|
|
220
365
|
for i, v in enumerate(blob[name]["data"]):
|
|
221
366
|
acc[i] += w * v
|
|
222
367
|
out[name] = {"shape": list(first[name]["shape"]), "data": acc}
|
|
223
|
-
return out
|
|
368
|
+
return out, events
|
|
@@ -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
|
-
|
|
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.
|
|
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.
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
#
|
|
12
|
-
#
|
|
13
|
-
|
|
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
|
+
#
|
|
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
|