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.
- {flashruntime-0.4.1/flashruntime.egg-info → flashruntime-0.4.2}/PKG-INFO +1 -1
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/fedavg_driver.py +33 -2
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/fedavg_weights.py +148 -3
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/protocol/v1alpha1.py +54 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/scheduler/__init__.py +64 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/modea.py +15 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2/flashruntime.egg-info}/PKG-INFO +1 -1
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime.egg-info/SOURCES.txt +2 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/pyproject.toml +14 -1
- {flashruntime-0.4.1 → 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_protocol_evidence.py +279 -0
- flashruntime-0.4.1/tests/test_fedavg_weights.py +0 -227
- {flashruntime-0.4.1 → flashruntime-0.4.2}/LICENSE +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/README.md +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/fedavg_worker.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/kmeans_driver.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/kmeans_shard.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/sgd_trainer.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/sharded_kmeans.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashml_workloads/sklearn_trial.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/artifacts/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/artifacts/store.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/backends/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/backends/base.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/backends/kuberay.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/checkpoint/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/checkpoint/catalog.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/checkpoint/local.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/checkpoint/store.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/integrations/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/integrations/huggingface.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/integrations/pytorch.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/integrations/sklearn.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/launchers/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/launchers/local.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/leases/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/leases/manager.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/leases/sqlite_store.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/leases/store.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/monitor/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/monitor/sampler.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/candidates.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/catalog.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/comm.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/explain.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/memory.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/resolve.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/selector.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/planner/timecost.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/profiling/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/protocol/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/protocol/plan_v1alpha1.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/providers/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/py.typed +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recipes/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recipes/command.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recovery/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recovery/policy.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recovery/signals.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/recovery/taxonomy.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/sdk.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/app.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/auth.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/checkpoints.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/cli.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/dashboard.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/service/ledger.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/strategies/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/strategies/command.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/torch/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/benchmarks.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/concepts/architecture.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/get-started.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/federated-averaging.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/huggingface.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/jobspec-and-isolation.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/pytorch.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/guides/sklearn.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/index.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/cli.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/integrations.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/sdk.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/reference/torch-helper.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/search-index.json +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/convnet.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/fault-tolerance.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/_docs/tutorials/sklearn-sweeps.html +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/flowmap.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/page.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/server.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/viewer/state.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/workloads/__init__.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime/workloads/command.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime.egg-info/dependency_links.txt +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime.egg-info/entry_points.txt +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime.egg-info/requires.txt +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/flashruntime.egg-info/top_level.txt +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/setup.cfg +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_auto_recovery.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_benchmarks.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_checkpoint.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_checkpoint_local.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_cli_submit.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_documentation.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_examples_e2e.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_fedavg_convergence.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_fedavg_worker.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_gpu_e2e.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_integrations.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_interfaces.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_kmeans_leases.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_kmeans_workload.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_kuberay_backend.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_launcher_local.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_leases.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_leases_scope.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_leases_sqlite.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_monitor_sampler.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_placement_gpu.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_placement_local_data.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_planner.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_protocol_gpu.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_protocol_local_datasets.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_protocol_v1alpha1.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_recipe_gpu.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_recipe_local_inputs.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_recipes_command.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_recovery.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_run_json.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_scheduler_isolation.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_sdk_submit.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_auth.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_auth_startup.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_checkpoints.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_command_recipe.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_delegation.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_fedavg.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_modea.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_service_write_scope.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_sgd_trainer.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_strategy_command.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_torch_helper.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_viewer_flowmap.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_viewer_page.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_viewer_server.py +0 -0
- {flashruntime-0.4.1 → flashruntime-0.4.2}/tests/test_viewer_state.py +0 -0
- {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.
|
|
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
|
|
@@ -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.
|
|
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.
|
|
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"]
|