secure-fl 2026.2.22.dev1__tar.gz → 2026.3.16.dev1__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.
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/.gitignore +12 -5
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/PKG-INFO +1 -1
- secure_fl-2026.3.16.dev1/docs/research paper/README.md +13 -0
- secure_fl-2026.3.16.dev1/src/secure_fl/.build_number +1 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/__init__.py +31 -42
- secure_fl-2026.3.16.dev1/src/secure_fl/benchmarks/__init__.py +30 -0
- secure_fl-2026.3.16.dev1/src/secure_fl/benchmarks/defaults.py +15 -0
- secure_fl-2026.3.16.dev1/src/secure_fl/benchmarks/execution.py +383 -0
- secure_fl-2026.3.16.dev1/src/secure_fl/benchmarks/pipeline.py +84 -0
- secure_fl-2026.3.16.dev1/src/secure_fl/benchmarks/reporting.py +164 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/cli/handlers.py +15 -29
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/cli/main.py +1 -1
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/core/config.py +3 -1
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/core/types.py +1 -3
- secure_fl-2026.3.16.dev1/src/secure_fl/data/__init__.py +85 -0
- secure_fl-2026.3.16.dev1/src/secure_fl/data/benchmark_data.py +204 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/aggregation.py +18 -24
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/client.py +207 -151
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/client_runtime.py +21 -3
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/client_utils.py +8 -2
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/server.py +42 -12
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/models/__init__.py +17 -34
- secure_fl-2026.3.16.dev1/src/secure_fl/models/factory.py +44 -0
- secure_fl-2026.3.16.dev1/src/secure_fl/models/mnist_cnn.py +30 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/client_proof_manager.py +54 -3
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/quantization.py +5 -0
- secure_fl-2026.2.22.dev1/src/secure_fl/.build_number +0 -1
- secure_fl-2026.2.22.dev1/src/secure_fl/models/cifar10.py +0 -110
- secure_fl-2026.2.22.dev1/src/secure_fl/models/flexible_mlp.py +0 -99
- secure_fl-2026.2.22.dev1/src/secure_fl/models/mnist.py +0 -92
- secure_fl-2026.2.22.dev1/src/secure_fl/models/resnet.py +0 -83
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/LICENSE +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/README.md +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/experiments/README.md +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/pyproject.toml +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/__main__.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/cli/__init__.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/cli/setup.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/cli/zkp_check.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/core/__init__.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/core/_version.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/core/exceptions.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/__init__.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/server_runtime.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/stability_monitor.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/models/simple.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/__init__.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/client_circuits/__init__.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/client_circuits/delta_bound.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/client_circuits/sgd_full_trace.cairo +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/server/pot12_final.ptau +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/server/test.txt +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/py.typed +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/utils/__init__.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/utils/helpers.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/utils/logging_config.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/__init__.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/base.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/proof_manager.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/proof_schema.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/server_proof_manager.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/server_setup.py +0 -0
- {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/server_snark_io.py +0 -0
|
@@ -102,12 +102,15 @@ dmypy.json
|
|
|
102
102
|
# ===========================
|
|
103
103
|
# Runtime logs and results
|
|
104
104
|
logs/
|
|
105
|
-
results
|
|
105
|
+
results/*
|
|
106
|
+
!results/gpu_convergence_mnist/
|
|
107
|
+
!results/gpu_convergence_cifar10/
|
|
108
|
+
!results/gpu_convergence_synthetic_small/
|
|
109
|
+
!results/gpu_convergence_medmnist_resnet18/
|
|
106
110
|
results_bak/
|
|
107
111
|
*.log
|
|
108
112
|
|
|
109
113
|
# Data directories
|
|
110
|
-
data/
|
|
111
114
|
datasets/
|
|
112
115
|
|
|
113
116
|
# ZKP artifacts
|
|
@@ -123,6 +126,13 @@ proofs/compiled/
|
|
|
123
126
|
*.vkey
|
|
124
127
|
*.zkey
|
|
125
128
|
|
|
129
|
+
# pysnark debug/trace output files
|
|
130
|
+
pysnark_eqs
|
|
131
|
+
pysnark_eqs_main
|
|
132
|
+
pysnark_schedule
|
|
133
|
+
pysnark_values
|
|
134
|
+
pysnark_wires
|
|
135
|
+
|
|
126
136
|
# Temporary files
|
|
127
137
|
temp/
|
|
128
138
|
tmp/
|
|
@@ -171,9 +181,6 @@ Thumbs.db
|
|
|
171
181
|
# Ruff cache
|
|
172
182
|
.ruff_cache/
|
|
173
183
|
|
|
174
|
-
# Benchmarking results
|
|
175
|
-
benchmarks/
|
|
176
|
-
performance_results/
|
|
177
184
|
|
|
178
185
|
# Docker
|
|
179
186
|
.dockerignore
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: secure-fl
|
|
3
|
-
Version: 2026.
|
|
3
|
+
Version: 2026.3.16.dev1
|
|
4
4
|
Summary: Dual-Verifiable Framework for Federated Learning using Zero-Knowledge Proofs
|
|
5
5
|
Project-URL: Homepage, https://github.com/krishantt/secure-fl
|
|
6
6
|
Project-URL: Bug Reports, https://github.com/krishantt/secure-fl/issues
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# Research Paper Draft
|
|
2
|
+
|
|
3
|
+
This folder contains a publication-style consolidation of the current project results.
|
|
4
|
+
|
|
5
|
+
- Source: `main.tex`
|
|
6
|
+
- References: `refs.bib`
|
|
7
|
+
- Output: `main.pdf`
|
|
8
|
+
|
|
9
|
+
Compile with Docker:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
docker run --rm -v "$(pwd)":/work -w /work texlive/texlive latexmk -pdf main.tex
|
|
13
|
+
```
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
2026.03.16:1
|
|
@@ -115,13 +115,12 @@ from .federation import StabilityMonitor
|
|
|
115
115
|
from .federation import create_client
|
|
116
116
|
from .federation import create_server_strategy
|
|
117
117
|
from .federation import start_client
|
|
118
|
-
from .models import
|
|
119
|
-
from .models import
|
|
120
|
-
from .models import MNISTModel
|
|
121
|
-
from .models import ResNetBlock
|
|
118
|
+
from .models import MNISTCNN
|
|
119
|
+
from .models import ResNet18Model
|
|
122
120
|
|
|
123
121
|
# Models
|
|
124
122
|
from .models import SimpleModel
|
|
123
|
+
from .models import model_fn
|
|
125
124
|
from .utils import AuditLogger
|
|
126
125
|
from .utils import PerformanceLogger
|
|
127
126
|
from .utils import SecureFlLogger
|
|
@@ -305,10 +304,9 @@ __all__ = [
|
|
|
305
304
|
"validate_proof_rigor",
|
|
306
305
|
# Models
|
|
307
306
|
"SimpleModel",
|
|
308
|
-
"
|
|
309
|
-
"
|
|
310
|
-
"
|
|
311
|
-
"ResNetBlock",
|
|
307
|
+
"MNISTCNN",
|
|
308
|
+
"ResNet18Model",
|
|
309
|
+
"model_fn",
|
|
312
310
|
]
|
|
313
311
|
|
|
314
312
|
|
|
@@ -317,20 +315,23 @@ def get_default_config_dict() -> dict[str, Any]:
|
|
|
317
315
|
return get_default_config().to_dict()
|
|
318
316
|
|
|
319
317
|
|
|
320
|
-
def
|
|
321
|
-
|
|
322
|
-
print(f"Secure FL v{__version__}")
|
|
323
|
-
print(f"Description: {__description__}")
|
|
324
|
-
print(f"Authors: {__author__}")
|
|
325
|
-
print()
|
|
318
|
+
def _command_available(command: list[str], timeout: int = 5) -> bool:
|
|
319
|
+
import subprocess
|
|
326
320
|
|
|
327
|
-
|
|
321
|
+
try:
|
|
322
|
+
result = subprocess.run(command, capture_output=True, timeout=timeout)
|
|
323
|
+
return result.returncode == 0
|
|
324
|
+
except (subprocess.SubprocessError, OSError, FileNotFoundError):
|
|
325
|
+
return False
|
|
326
|
+
|
|
327
|
+
|
|
328
|
+
def _get_component_status() -> dict[str, bool]:
|
|
328
329
|
components = {
|
|
329
|
-
"Flower":
|
|
330
|
-
"PyTorch":
|
|
331
|
-
"Cairo":
|
|
332
|
-
"Circom":
|
|
333
|
-
"SnarkJS":
|
|
330
|
+
"Flower": False,
|
|
331
|
+
"PyTorch": False,
|
|
332
|
+
"Cairo": _command_available(["cairo-compile", "--version"]),
|
|
333
|
+
"Circom": _command_available(["circom", "--version"]),
|
|
334
|
+
"SnarkJS": _command_available(["snarkjs", "help"]),
|
|
334
335
|
}
|
|
335
336
|
|
|
336
337
|
try:
|
|
@@ -338,37 +339,26 @@ def print_system_info() -> None:
|
|
|
338
339
|
|
|
339
340
|
components["PyTorch"] = True
|
|
340
341
|
except ImportError:
|
|
341
|
-
|
|
342
|
+
pass
|
|
342
343
|
|
|
343
344
|
try:
|
|
344
345
|
import flwr # noqa: F401
|
|
345
346
|
|
|
346
347
|
components["Flower"] = True
|
|
347
348
|
except ImportError:
|
|
348
|
-
|
|
349
|
+
pass
|
|
349
350
|
|
|
350
|
-
|
|
351
|
-
import subprocess
|
|
351
|
+
return components
|
|
352
352
|
|
|
353
|
-
try:
|
|
354
|
-
result = subprocess.run(
|
|
355
|
-
["cairo-compile", "--version"], capture_output=True, timeout=5
|
|
356
|
-
)
|
|
357
|
-
components["Cairo"] = result.returncode == 0
|
|
358
|
-
except (subprocess.SubprocessError, OSError, FileNotFoundError):
|
|
359
|
-
pass
|
|
360
353
|
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
354
|
+
def print_system_info() -> None:
|
|
355
|
+
"""Print system information and component status."""
|
|
356
|
+
print(f"Secure FL v{__version__}")
|
|
357
|
+
print(f"Description: {__description__}")
|
|
358
|
+
print(f"Authors: {__author__}")
|
|
359
|
+
print()
|
|
366
360
|
|
|
367
|
-
|
|
368
|
-
result = subprocess.run(["snarkjs", "help"], capture_output=True, timeout=5)
|
|
369
|
-
components["SnarkJS"] = result.returncode == 0
|
|
370
|
-
except (subprocess.SubprocessError, OSError, FileNotFoundError):
|
|
371
|
-
pass
|
|
361
|
+
components = _get_component_status()
|
|
372
362
|
|
|
373
363
|
print("Component Status:")
|
|
374
364
|
for component, available in components.items():
|
|
@@ -447,7 +437,6 @@ def create_secure_fl_setup(
|
|
|
447
437
|
if isinstance(client_config.proof_rigor, ProofRigor)
|
|
448
438
|
else client_config.proof_rigor
|
|
449
439
|
),
|
|
450
|
-
quantize_weights=client_config.quantize_weights,
|
|
451
440
|
quantization_bits=(
|
|
452
441
|
client_config.quantization_bits.value
|
|
453
442
|
if isinstance(client_config.quantization_bits, QuantizationBits)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Benchmark orchestration helpers for experiment entrypoints."""
|
|
2
|
+
|
|
3
|
+
from secure_fl.data.benchmark_data import SUPPORTED_DATASETS
|
|
4
|
+
from secure_fl.data.benchmark_data import load_dataset
|
|
5
|
+
from secure_fl.data.benchmark_data import maybe_subsample_dataset
|
|
6
|
+
from secure_fl.data.benchmark_data import normalize_datasets
|
|
7
|
+
from secure_fl.data.benchmark_data import partition_dataset
|
|
8
|
+
from secure_fl.models.factory import model_fn
|
|
9
|
+
|
|
10
|
+
from .defaults import PAPER_DEFAULT_ROUNDS
|
|
11
|
+
from .defaults import resolve_num_rounds
|
|
12
|
+
from .execution import run_training_rounds
|
|
13
|
+
from .pipeline import run_single
|
|
14
|
+
from .reporting import build_tradeoff_report
|
|
15
|
+
from .reporting import summarize_repeats
|
|
16
|
+
|
|
17
|
+
__all__ = [
|
|
18
|
+
"PAPER_DEFAULT_ROUNDS",
|
|
19
|
+
"resolve_num_rounds",
|
|
20
|
+
"SUPPORTED_DATASETS",
|
|
21
|
+
"normalize_datasets",
|
|
22
|
+
"load_dataset",
|
|
23
|
+
"partition_dataset",
|
|
24
|
+
"maybe_subsample_dataset",
|
|
25
|
+
"model_fn",
|
|
26
|
+
"run_single",
|
|
27
|
+
"run_training_rounds",
|
|
28
|
+
"summarize_repeats",
|
|
29
|
+
"build_tradeoff_report",
|
|
30
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""Default benchmark profiles used by publication-oriented experiments."""
|
|
2
|
+
|
|
3
|
+
PAPER_DEFAULT_ROUNDS = {
|
|
4
|
+
"mnist": 100,
|
|
5
|
+
"cifar10": 150,
|
|
6
|
+
"medmnist": 120,
|
|
7
|
+
"synthetic_small": 100,
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def resolve_num_rounds(dataset: str, requested_num_rounds: int) -> int:
|
|
12
|
+
"""Resolve round count using paper defaults when request is 0."""
|
|
13
|
+
if requested_num_rounds > 0:
|
|
14
|
+
return requested_num_rounds
|
|
15
|
+
return int(PAPER_DEFAULT_ROUNDS.get(dataset, 100))
|
|
@@ -0,0 +1,383 @@
|
|
|
1
|
+
"""Round-execution utilities shared by benchmark entrypoints."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import time
|
|
7
|
+
from typing import Any
|
|
8
|
+
|
|
9
|
+
import numpy as np
|
|
10
|
+
import torch
|
|
11
|
+
from torch.utils.data import DataLoader
|
|
12
|
+
|
|
13
|
+
from secure_fl.federation.stability_monitor import StabilityMonitor
|
|
14
|
+
from secure_fl.zkp.proof_manager import ServerProofManager
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def evaluate_global(
|
|
18
|
+
model_fn: Any,
|
|
19
|
+
params: list[np.ndarray],
|
|
20
|
+
test_set: torch.utils.data.Dataset,
|
|
21
|
+
batch_size: int,
|
|
22
|
+
) -> dict[str, float]:
|
|
23
|
+
"""Evaluate current global model on the provided test set."""
|
|
24
|
+
from secure_fl import ndarrays_to_torch
|
|
25
|
+
|
|
26
|
+
model = model_fn()
|
|
27
|
+
ndarrays_to_torch(model, params)
|
|
28
|
+
model.eval()
|
|
29
|
+
loader = DataLoader(test_set, batch_size=batch_size, shuffle=False)
|
|
30
|
+
criterion = torch.nn.CrossEntropyLoss()
|
|
31
|
+
|
|
32
|
+
correct = 0
|
|
33
|
+
total = 0
|
|
34
|
+
total_loss = 0.0
|
|
35
|
+
with torch.no_grad():
|
|
36
|
+
for x, y in loader:
|
|
37
|
+
if isinstance(y, torch.Tensor) and y.ndim > 1:
|
|
38
|
+
y = y.view(-1)
|
|
39
|
+
out = model(x)
|
|
40
|
+
loss = criterion(out, y)
|
|
41
|
+
total_loss += float(loss.item()) * x.size(0)
|
|
42
|
+
pred = out.argmax(dim=1)
|
|
43
|
+
correct += int((pred == y).sum().item())
|
|
44
|
+
total += int(y.size(0))
|
|
45
|
+
|
|
46
|
+
return {
|
|
47
|
+
"accuracy": (correct / total) if total else 0.0,
|
|
48
|
+
"loss": (total_loss / total) if total else 0.0,
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def run_training_rounds(
|
|
53
|
+
*,
|
|
54
|
+
clients: list[Any],
|
|
55
|
+
config: dict[str, Any],
|
|
56
|
+
model_fn: Any,
|
|
57
|
+
global_params: list[np.ndarray],
|
|
58
|
+
aggregator: Any,
|
|
59
|
+
test_set: torch.utils.data.Dataset,
|
|
60
|
+
num_rounds: int,
|
|
61
|
+
clients_per_round: int,
|
|
62
|
+
local_epochs: int,
|
|
63
|
+
batch_size: int,
|
|
64
|
+
require_real_proofs: bool,
|
|
65
|
+
min_proof_coverage: float,
|
|
66
|
+
dataset: str,
|
|
67
|
+
) -> dict[str, Any]:
|
|
68
|
+
"""Execute FL rounds and return round-level and summary benchmark metrics."""
|
|
69
|
+
is_dynamic = config["proof_rigor"] == "dynamic"
|
|
70
|
+
stability_monitor: StabilityMonitor | None = (
|
|
71
|
+
StabilityMonitor() if is_dynamic else None
|
|
72
|
+
)
|
|
73
|
+
current_rigor = "high" if is_dynamic else config["proof_rigor"]
|
|
74
|
+
server_proof_manager = ServerProofManager() if config["enable_zkp"] else None
|
|
75
|
+
|
|
76
|
+
rounds: list[dict[str, Any]] = []
|
|
77
|
+
proof_coverage_values: list[float] = []
|
|
78
|
+
|
|
79
|
+
for round_idx in range(num_rounds):
|
|
80
|
+
round_start = time.perf_counter()
|
|
81
|
+
old_global_params = [p.copy() for p in global_params]
|
|
82
|
+
model_payload_bytes = float(_ndarrays_nbytes(old_global_params))
|
|
83
|
+
updates: list[list[np.ndarray]] = []
|
|
84
|
+
weights: list[int] = []
|
|
85
|
+
client_proof_times: list[float] = []
|
|
86
|
+
client_verification_times: list[float] = []
|
|
87
|
+
proof_payload_count = 0
|
|
88
|
+
verified_client_count = 0
|
|
89
|
+
losses: list[float] = []
|
|
90
|
+
total_client_upload_bytes = 0.0
|
|
91
|
+
total_client_update_bytes = 0.0
|
|
92
|
+
total_client_proof_bytes = 0.0
|
|
93
|
+
|
|
94
|
+
sampled_count = min(max(1, clients_per_round), len(clients))
|
|
95
|
+
sampled_clients = list(
|
|
96
|
+
np.random.choice(clients, size=sampled_count, replace=False)
|
|
97
|
+
)
|
|
98
|
+
|
|
99
|
+
for client in sampled_clients:
|
|
100
|
+
updated_params, num_examples, metrics = client.fit(
|
|
101
|
+
global_params,
|
|
102
|
+
{
|
|
103
|
+
"server_round": round_idx + 1,
|
|
104
|
+
"local_epochs": local_epochs,
|
|
105
|
+
"learning_rate": 0.01,
|
|
106
|
+
"proof_rigor": current_rigor,
|
|
107
|
+
"enable_zkp": config["enable_zkp"],
|
|
108
|
+
},
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
client_proof_times.append(float(metrics.get("proof_time", 0.0)))
|
|
112
|
+
raw_proof = metrics.get("zkp_proof")
|
|
113
|
+
update_bytes = float(_ndarrays_nbytes(updated_params))
|
|
114
|
+
proof_bytes = float(_serialized_size_bytes(raw_proof))
|
|
115
|
+
total_client_update_bytes += update_bytes
|
|
116
|
+
total_client_proof_bytes += proof_bytes
|
|
117
|
+
total_client_upload_bytes += update_bytes + proof_bytes
|
|
118
|
+
has_real_proof = _has_real_proof(raw_proof)
|
|
119
|
+
if has_real_proof:
|
|
120
|
+
proof_payload_count += 1
|
|
121
|
+
|
|
122
|
+
is_verified = True
|
|
123
|
+
if config["enable_zkp"]:
|
|
124
|
+
is_verified = False
|
|
125
|
+
if has_real_proof and server_proof_manager is not None:
|
|
126
|
+
verify_start = time.perf_counter()
|
|
127
|
+
is_verified = server_proof_manager.verify_client_proof(
|
|
128
|
+
raw_proof,
|
|
129
|
+
updated_parameters=updated_params,
|
|
130
|
+
old_global_params=old_global_params,
|
|
131
|
+
)
|
|
132
|
+
client_verification_times.append(time.perf_counter() - verify_start)
|
|
133
|
+
|
|
134
|
+
if is_verified:
|
|
135
|
+
updates.append(updated_params)
|
|
136
|
+
weights.append(num_examples)
|
|
137
|
+
losses.append(float(metrics.get("train_loss", 0.0)))
|
|
138
|
+
verified_client_count += 1
|
|
139
|
+
|
|
140
|
+
if not updates or not weights:
|
|
141
|
+
raise RuntimeError(
|
|
142
|
+
f"No verified client updates available for {dataset}/{config['name']} "
|
|
143
|
+
f"round {round_idx + 1}."
|
|
144
|
+
)
|
|
145
|
+
|
|
146
|
+
weight_sum = sum(weights)
|
|
147
|
+
norm_weights = [w / weight_sum for w in weights]
|
|
148
|
+
global_params = aggregator.aggregate(
|
|
149
|
+
updates, norm_weights, round_idx + 1, global_params
|
|
150
|
+
)
|
|
151
|
+
|
|
152
|
+
server_proof_time = 0.0
|
|
153
|
+
server_proof_generated = 0.0
|
|
154
|
+
server_proof_payload_bytes = 0.0
|
|
155
|
+
rigor_this_round = current_rigor
|
|
156
|
+
if (
|
|
157
|
+
config["enable_zkp"]
|
|
158
|
+
and server_proof_manager is not None
|
|
159
|
+
and should_generate_server_proof(rigor_this_round, round_idx + 1)
|
|
160
|
+
):
|
|
161
|
+
proof_start = time.perf_counter()
|
|
162
|
+
server_proof = server_proof_manager.generate_server_proof(
|
|
163
|
+
client_updates=updates,
|
|
164
|
+
client_weights=norm_weights,
|
|
165
|
+
aggregated_params=global_params,
|
|
166
|
+
old_params=old_global_params,
|
|
167
|
+
momentum=aggregator.server_momentum
|
|
168
|
+
if aggregator.server_momentum is not None
|
|
169
|
+
else [np.zeros_like(p) for p in global_params],
|
|
170
|
+
momentum_coeff=aggregator.momentum,
|
|
171
|
+
)
|
|
172
|
+
server_proof_time = float(time.perf_counter() - proof_start)
|
|
173
|
+
server_proof_generated = 1.0 if server_proof else 0.0
|
|
174
|
+
server_proof_payload_bytes = float(_serialized_size_bytes(server_proof))
|
|
175
|
+
|
|
176
|
+
if stability_monitor is not None:
|
|
177
|
+
client_metrics_agg: dict[str, Any] = {}
|
|
178
|
+
if losses:
|
|
179
|
+
client_metrics_agg["train_loss"] = float(np.mean(losses))
|
|
180
|
+
if client_proof_times:
|
|
181
|
+
client_metrics_agg["proof_time"] = float(np.mean(client_proof_times))
|
|
182
|
+
stability_monitor.update(
|
|
183
|
+
global_params, round_idx + 1, client_metrics_agg or None
|
|
184
|
+
)
|
|
185
|
+
current_rigor = stability_monitor.get_recommended_rigor()
|
|
186
|
+
|
|
187
|
+
proof_coverage = (
|
|
188
|
+
proof_payload_count / len(sampled_clients) if sampled_clients else 0.0
|
|
189
|
+
)
|
|
190
|
+
proof_coverage_values.append(proof_coverage)
|
|
191
|
+
|
|
192
|
+
if (
|
|
193
|
+
config["enable_zkp"]
|
|
194
|
+
and require_real_proofs
|
|
195
|
+
and proof_coverage < min_proof_coverage
|
|
196
|
+
):
|
|
197
|
+
raise RuntimeError(
|
|
198
|
+
"Real proof requirement failed for "
|
|
199
|
+
f"{dataset}/{config['name']} round {round_idx + 1}: "
|
|
200
|
+
f"coverage={proof_coverage:.3f}, expected>={min_proof_coverage:.3f}. "
|
|
201
|
+
"Check Circom/SnarkJS/PySNARK setup before benchmarking."
|
|
202
|
+
)
|
|
203
|
+
|
|
204
|
+
eval_metrics = evaluate_global(model_fn, global_params, test_set, batch_size)
|
|
205
|
+
server_model_broadcast_bytes = float(len(sampled_clients)) * model_payload_bytes
|
|
206
|
+
server_proof_broadcast_bytes = (
|
|
207
|
+
float(len(sampled_clients)) * server_proof_payload_bytes
|
|
208
|
+
)
|
|
209
|
+
total_round_communication_bytes = (
|
|
210
|
+
total_client_upload_bytes
|
|
211
|
+
+ server_model_broadcast_bytes
|
|
212
|
+
+ server_proof_broadcast_bytes
|
|
213
|
+
)
|
|
214
|
+
rounds.append(
|
|
215
|
+
{
|
|
216
|
+
"round": float(round_idx + 1),
|
|
217
|
+
"round_time_sec": float(time.perf_counter() - round_start),
|
|
218
|
+
"avg_client_proof_time_sec": float(
|
|
219
|
+
np.mean(client_proof_times) if client_proof_times else 0.0
|
|
220
|
+
),
|
|
221
|
+
"avg_client_verification_time_sec": float(
|
|
222
|
+
np.mean(client_verification_times)
|
|
223
|
+
if client_verification_times
|
|
224
|
+
else 0.0
|
|
225
|
+
),
|
|
226
|
+
"server_proof_time_sec": float(server_proof_time),
|
|
227
|
+
"server_proof_generated": float(server_proof_generated),
|
|
228
|
+
"proof_payload_coverage": float(proof_coverage),
|
|
229
|
+
"verified_clients": float(verified_client_count),
|
|
230
|
+
"submitted_clients": float(len(sampled_clients)),
|
|
231
|
+
"client_upload_bytes": float(total_client_upload_bytes),
|
|
232
|
+
"client_update_bytes": float(total_client_update_bytes),
|
|
233
|
+
"client_proof_payload_bytes": float(total_client_proof_bytes),
|
|
234
|
+
"avg_client_upload_bytes": float(total_client_upload_bytes)
|
|
235
|
+
/ float(len(sampled_clients) if sampled_clients else 1),
|
|
236
|
+
"server_model_broadcast_bytes": float(server_model_broadcast_bytes),
|
|
237
|
+
"server_proof_payload_bytes": float(server_proof_payload_bytes),
|
|
238
|
+
"server_proof_broadcast_bytes": float(server_proof_broadcast_bytes),
|
|
239
|
+
"total_communication_bytes": float(total_round_communication_bytes),
|
|
240
|
+
"avg_train_loss": float(np.mean(losses) if losses else 0.0),
|
|
241
|
+
"accuracy": eval_metrics["accuracy"],
|
|
242
|
+
"eval_loss": eval_metrics["loss"],
|
|
243
|
+
"proof_rigor": rigor_this_round,
|
|
244
|
+
}
|
|
245
|
+
)
|
|
246
|
+
|
|
247
|
+
return {
|
|
248
|
+
"global_params": global_params,
|
|
249
|
+
"rounds": rounds,
|
|
250
|
+
"summary": {
|
|
251
|
+
"final_accuracy": rounds[-1]["accuracy"] if rounds else 0.0,
|
|
252
|
+
"final_eval_loss": rounds[-1]["eval_loss"] if rounds else 0.0,
|
|
253
|
+
"mean_round_time_sec": float(np.mean([r["round_time_sec"] for r in rounds]))
|
|
254
|
+
if rounds
|
|
255
|
+
else 0.0,
|
|
256
|
+
"mean_client_proof_time_sec": float(
|
|
257
|
+
np.mean([r["avg_client_proof_time_sec"] for r in rounds])
|
|
258
|
+
)
|
|
259
|
+
if rounds
|
|
260
|
+
else 0.0,
|
|
261
|
+
"mean_client_verification_time_sec": float(
|
|
262
|
+
np.mean([r["avg_client_verification_time_sec"] for r in rounds])
|
|
263
|
+
)
|
|
264
|
+
if rounds
|
|
265
|
+
else 0.0,
|
|
266
|
+
"mean_server_proof_time_sec": float(
|
|
267
|
+
np.mean([r["server_proof_time_sec"] for r in rounds])
|
|
268
|
+
)
|
|
269
|
+
if rounds
|
|
270
|
+
else 0.0,
|
|
271
|
+
"server_proof_generation_rate": float(
|
|
272
|
+
np.mean([r["server_proof_generated"] for r in rounds])
|
|
273
|
+
)
|
|
274
|
+
if rounds
|
|
275
|
+
else 0.0,
|
|
276
|
+
"mean_total_proof_time_sec": float(
|
|
277
|
+
np.mean(
|
|
278
|
+
[
|
|
279
|
+
r["avg_client_proof_time_sec"] + r["server_proof_time_sec"]
|
|
280
|
+
for r in rounds
|
|
281
|
+
]
|
|
282
|
+
)
|
|
283
|
+
)
|
|
284
|
+
if rounds
|
|
285
|
+
else 0.0,
|
|
286
|
+
"mean_proof_payload_coverage": float(np.mean(proof_coverage_values))
|
|
287
|
+
if proof_coverage_values
|
|
288
|
+
else 0.0,
|
|
289
|
+
"mean_client_upload_bytes": float(
|
|
290
|
+
np.mean([r["client_upload_bytes"] for r in rounds])
|
|
291
|
+
)
|
|
292
|
+
if rounds
|
|
293
|
+
else 0.0,
|
|
294
|
+
"mean_client_update_bytes": float(
|
|
295
|
+
np.mean([r["client_update_bytes"] for r in rounds])
|
|
296
|
+
)
|
|
297
|
+
if rounds
|
|
298
|
+
else 0.0,
|
|
299
|
+
"mean_client_proof_payload_bytes": float(
|
|
300
|
+
np.mean([r["client_proof_payload_bytes"] for r in rounds])
|
|
301
|
+
)
|
|
302
|
+
if rounds
|
|
303
|
+
else 0.0,
|
|
304
|
+
"mean_avg_client_upload_bytes": float(
|
|
305
|
+
np.mean([r["avg_client_upload_bytes"] for r in rounds])
|
|
306
|
+
)
|
|
307
|
+
if rounds
|
|
308
|
+
else 0.0,
|
|
309
|
+
"mean_server_model_broadcast_bytes": float(
|
|
310
|
+
np.mean([r["server_model_broadcast_bytes"] for r in rounds])
|
|
311
|
+
)
|
|
312
|
+
if rounds
|
|
313
|
+
else 0.0,
|
|
314
|
+
"mean_server_proof_payload_bytes": float(
|
|
315
|
+
np.mean([r["server_proof_payload_bytes"] for r in rounds])
|
|
316
|
+
)
|
|
317
|
+
if rounds
|
|
318
|
+
else 0.0,
|
|
319
|
+
"mean_server_proof_broadcast_bytes": float(
|
|
320
|
+
np.mean([r["server_proof_broadcast_bytes"] for r in rounds])
|
|
321
|
+
)
|
|
322
|
+
if rounds
|
|
323
|
+
else 0.0,
|
|
324
|
+
"mean_total_communication_bytes": float(
|
|
325
|
+
np.mean([r["total_communication_bytes"] for r in rounds])
|
|
326
|
+
)
|
|
327
|
+
if rounds
|
|
328
|
+
else 0.0,
|
|
329
|
+
},
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
|
|
333
|
+
def should_generate_server_proof(rigor: str, server_round: int) -> bool:
|
|
334
|
+
"""Match server strategy cadence for server proof generation."""
|
|
335
|
+
if rigor == "high":
|
|
336
|
+
return True
|
|
337
|
+
if rigor == "medium":
|
|
338
|
+
return server_round % 2 == 0
|
|
339
|
+
if rigor == "low":
|
|
340
|
+
return server_round % 5 == 0
|
|
341
|
+
return False
|
|
342
|
+
|
|
343
|
+
|
|
344
|
+
def _has_real_proof(raw_proof: Any) -> bool:
|
|
345
|
+
if not raw_proof:
|
|
346
|
+
return False
|
|
347
|
+
|
|
348
|
+
proof_obj: dict[str, Any] | None = None
|
|
349
|
+
if isinstance(raw_proof, str):
|
|
350
|
+
try:
|
|
351
|
+
parsed = json.loads(raw_proof)
|
|
352
|
+
if isinstance(parsed, dict):
|
|
353
|
+
proof_obj = parsed
|
|
354
|
+
except json.JSONDecodeError:
|
|
355
|
+
return False
|
|
356
|
+
elif isinstance(raw_proof, dict):
|
|
357
|
+
proof_obj = raw_proof
|
|
358
|
+
|
|
359
|
+
if not proof_obj:
|
|
360
|
+
return False
|
|
361
|
+
|
|
362
|
+
pysnark = proof_obj.get("pysnark")
|
|
363
|
+
if isinstance(pysnark, dict):
|
|
364
|
+
return bool(pysnark.get("enabled", False)) and "error" not in pysnark
|
|
365
|
+
|
|
366
|
+
return False
|
|
367
|
+
|
|
368
|
+
|
|
369
|
+
def _ndarrays_nbytes(arrays: list[np.ndarray]) -> int:
|
|
370
|
+
return int(sum(int(arr.nbytes) for arr in arrays))
|
|
371
|
+
|
|
372
|
+
|
|
373
|
+
def _serialized_size_bytes(payload: Any) -> int:
|
|
374
|
+
if payload is None:
|
|
375
|
+
return 0
|
|
376
|
+
if isinstance(payload, bytes):
|
|
377
|
+
return len(payload)
|
|
378
|
+
if isinstance(payload, str):
|
|
379
|
+
return len(payload.encode("utf-8"))
|
|
380
|
+
try:
|
|
381
|
+
return len(json.dumps(payload, sort_keys=True).encode("utf-8"))
|
|
382
|
+
except Exception:
|
|
383
|
+
return 0
|