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.
Files changed (63) hide show
  1. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/.gitignore +12 -5
  2. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/PKG-INFO +1 -1
  3. secure_fl-2026.3.16.dev1/docs/research paper/README.md +13 -0
  4. secure_fl-2026.3.16.dev1/src/secure_fl/.build_number +1 -0
  5. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/__init__.py +31 -42
  6. secure_fl-2026.3.16.dev1/src/secure_fl/benchmarks/__init__.py +30 -0
  7. secure_fl-2026.3.16.dev1/src/secure_fl/benchmarks/defaults.py +15 -0
  8. secure_fl-2026.3.16.dev1/src/secure_fl/benchmarks/execution.py +383 -0
  9. secure_fl-2026.3.16.dev1/src/secure_fl/benchmarks/pipeline.py +84 -0
  10. secure_fl-2026.3.16.dev1/src/secure_fl/benchmarks/reporting.py +164 -0
  11. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/cli/handlers.py +15 -29
  12. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/cli/main.py +1 -1
  13. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/core/config.py +3 -1
  14. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/core/types.py +1 -3
  15. secure_fl-2026.3.16.dev1/src/secure_fl/data/__init__.py +85 -0
  16. secure_fl-2026.3.16.dev1/src/secure_fl/data/benchmark_data.py +204 -0
  17. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/aggregation.py +18 -24
  18. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/client.py +207 -151
  19. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/client_runtime.py +21 -3
  20. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/client_utils.py +8 -2
  21. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/server.py +42 -12
  22. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/models/__init__.py +17 -34
  23. secure_fl-2026.3.16.dev1/src/secure_fl/models/factory.py +44 -0
  24. secure_fl-2026.3.16.dev1/src/secure_fl/models/mnist_cnn.py +30 -0
  25. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/client_proof_manager.py +54 -3
  26. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/quantization.py +5 -0
  27. secure_fl-2026.2.22.dev1/src/secure_fl/.build_number +0 -1
  28. secure_fl-2026.2.22.dev1/src/secure_fl/models/cifar10.py +0 -110
  29. secure_fl-2026.2.22.dev1/src/secure_fl/models/flexible_mlp.py +0 -99
  30. secure_fl-2026.2.22.dev1/src/secure_fl/models/mnist.py +0 -92
  31. secure_fl-2026.2.22.dev1/src/secure_fl/models/resnet.py +0 -83
  32. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/LICENSE +0 -0
  33. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/README.md +0 -0
  34. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/experiments/README.md +0 -0
  35. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/pyproject.toml +0 -0
  36. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/__main__.py +0 -0
  37. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/cli/__init__.py +0 -0
  38. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/cli/setup.py +0 -0
  39. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/cli/zkp_check.py +0 -0
  40. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/core/__init__.py +0 -0
  41. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/core/_version.py +0 -0
  42. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/core/exceptions.py +0 -0
  43. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/__init__.py +0 -0
  44. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/server_runtime.py +0 -0
  45. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/federation/stability_monitor.py +0 -0
  46. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/models/simple.py +0 -0
  47. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/__init__.py +0 -0
  48. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/client_circuits/__init__.py +0 -0
  49. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/client_circuits/delta_bound.py +0 -0
  50. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/client_circuits/sgd_full_trace.cairo +0 -0
  51. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/server/pot12_final.ptau +0 -0
  52. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/proofs/server/test.txt +0 -0
  53. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/py.typed +0 -0
  54. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/utils/__init__.py +0 -0
  55. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/utils/helpers.py +0 -0
  56. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/utils/logging_config.py +0 -0
  57. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/__init__.py +0 -0
  58. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/base.py +0 -0
  59. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/proof_manager.py +0 -0
  60. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/proof_schema.py +0 -0
  61. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/server_proof_manager.py +0 -0
  62. {secure_fl-2026.2.22.dev1 → secure_fl-2026.3.16.dev1}/src/secure_fl/zkp/server_setup.py +0 -0
  63. {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.2.22.dev1
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 CIFAR10Model
119
- from .models import FlexibleMLP
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
- "MNISTModel",
309
- "CIFAR10Model",
310
- "FlexibleMLP",
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 print_system_info() -> None:
321
- """Print system information and component status."""
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
- # Check component availability
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": True, # Always available if imported
330
- "PyTorch": True,
331
- "Cairo": False, # Would need to check installation
332
- "Circom": False, # Would need to check installation
333
- "SnarkJS": False, # Would need to check installation
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
- components["PyTorch"] = False
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
- components["Flower"] = False
349
+ pass
349
350
 
350
- # Check external tools
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
- try:
362
- result = subprocess.run(["circom", "--version"], capture_output=True, timeout=5)
363
- components["Circom"] = result.returncode == 0
364
- except (subprocess.SubprocessError, OSError, FileNotFoundError):
365
- pass
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
- try:
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