File size: 17,557 Bytes
8af5729 | 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275 276 277 278 279 280 281 282 283 284 285 286 287 288 289 290 291 292 293 294 295 296 297 298 299 300 301 302 303 304 305 306 307 308 309 310 311 312 313 314 315 316 317 318 319 320 321 322 323 324 325 326 327 328 329 330 331 332 333 334 335 336 337 338 339 340 341 342 343 344 345 346 347 348 349 350 351 352 353 354 355 356 357 358 359 360 361 362 363 364 365 366 367 368 369 370 371 372 373 374 375 376 377 378 379 380 381 382 383 384 385 386 387 388 389 390 391 392 393 394 395 396 397 398 399 400 | """Unit tests for GradientCompressionEngine and FederatedCommunicationProfiler.
Tests cover:
- Top-K gradient sparsification (accuracy, sparsity invariants, thresholding, edge cases)
- Lossless compression and decompression roundtrips (zlib / zstd format, bit-exact recovery)
- Binary quantization (FP32, FP16 half-precision, INT8 symmetric affine)
- Coordinate sparse (COO) encoding and decoding
- Wire payload transfer size hierarchy and compression profiling
- Protocol communication profiling (FedAvg vs FedProx vs SCAFFOLD vs Curve25519 SecAgg vs TenSEAL CKKS)
- Integration with FederatedLearningEngine communication telemetry
"""
from __future__ import annotations
import math
from unittest.mock import MagicMock
import numpy as np
import pytest
from app.application.services.fl_engine import FederatedLearningEngine
from app.domain.enums import AggregationMethod
from app.infrastructure.security.compression_engine import (
BandwidthBenchmarkSuiteResult,
FederatedCommunicationProfiler,
FederatedProtocol,
GradientCompressionEngine,
ProtocolCommunicationProfile,
SerializationFormat,
)
class TestTopKSparsification:
"""Validates Top-K gradient sparsification algorithms and boundary behavior."""
@pytest.fixture
def engine(self) -> GradientCompressionEngine:
return GradientCompressionEngine(default_k_percent=0.20)
def test_sparsify_top_k_retains_exact_percentage(self, engine: GradientCompressionEngine) -> None:
"""K=20% on 1,000 items retains exactly 200 non-zero elements."""
weights = [float(i) for i in range(1, 1001)]
sparsified = engine.sparsify_top_k(weights, k_percent=0.20)
assert len(sparsified) == 1000
non_zero = [w for w in sparsified if w != 0.0]
assert len(non_zero) == 200
# The retained 200 should be 801 to 1000
assert min(non_zero) == 801.0
assert max(non_zero) == 1000.0
def test_sparsify_top_k_preserves_large_negative_magnitudes(
self, engine: GradientCompressionEngine
) -> None:
"""Top-K operates on absolute magnitudes, preserving large negative gradients."""
weights = [-100.0, 0.5, -0.2, 50.0, 0.1, -80.0, 0.01, 20.0, -0.05, 0.02]
# Keep top 30% (3 items): -100.0, -80.0, 50.0
sparsified = engine.sparsify_top_k(weights, k_percent=0.30)
retained = {w for w in sparsified if w != 0.0}
assert retained == {-100.0, -80.0, 50.0}
def test_sparsify_top_k_clamping_and_defaults(self, engine: GradientCompressionEngine) -> None:
"""k_percent is clamped between 0.01 and 1.0."""
weights = [1.0, 2.0, 3.0, 4.0, 5.0]
# None uses default_k_percent (0.20 -> 1 element)
default_res = engine.sparsify_top_k(weights, k_percent=None)
assert sum(1 for w in default_res if w != 0.0) == 1
# 0.0 clamped to 0.01 (at least 1 element)
zero_res = engine.sparsify_top_k(weights, k_percent=0.0)
assert sum(1 for w in zero_res if w != 0.0) == 1
# 1.5 clamped to 1.0 (all 5 elements retained)
over_res = engine.sparsify_top_k(weights, k_percent=1.5)
assert sum(1 for w in over_res if w != 0.0) == 5
def test_sparsify_top_k_empty_and_zero_inputs(self, engine: GradientCompressionEngine) -> None:
"""Handles empty and all-zero weight vectors safely."""
assert engine.sparsify_top_k([]) == []
assert engine.sparsify_top_k(None) == [] # type: ignore[arg-type]
all_zeros = [0.0] * 50
res = engine.sparsify_top_k(all_zeros, k_percent=0.20)
assert len(res) == 50
assert all(w == 0.0 for w in res)
class TestLosslessCompression:
"""Validates lossless byte compression and decompression."""
@pytest.fixture
def engine(self) -> GradientCompressionEngine:
return GradientCompressionEngine(compression_level=6)
def test_compress_decompress_roundtrip(self, engine: GradientCompressionEngine) -> None:
"""Compressed payload decompresses to identical bytes."""
original = b"FEDERATED_LEARNING_BANK_GRADIENT_UPDATE_PAYLOAD_TEST_VECTOR_12345" * 20
compressed = engine.compress_payload(original)
assert len(compressed) < len(original)
decompressed = engine.decompress_payload(compressed)
assert decompressed == original
def test_compress_empty_payload(self, engine: GradientCompressionEngine) -> None:
"""Empty input bytes returns empty bytes."""
assert engine.compress_payload(b"") == b""
assert engine.decompress_payload(b"") == b""
def test_compress_gradient_vector_backward_compatibility(
self, engine: GradientCompressionEngine
) -> None:
"""Maintains backward compatibility with legacy compress_gradient_vector."""
weights = [0.1 * i for i in range(100)]
meta = engine.compress_gradient_vector(weights, k_percent=0.10)
assert meta["original_length"] == 100
assert meta["sparsified_length"] == 100
assert meta["compressed_size"] > 0
assert meta["raw_size"] > meta["compressed_size"]
assert isinstance(meta["compressed_bytes"], bytes)
class TestBinaryQuantization:
"""Validates FP32, FP16 half-precision, and INT8 uniform symmetric quantization."""
@pytest.fixture
def engine(self) -> GradientCompressionEngine:
return GradientCompressionEngine()
def test_fp32_encode_decode_roundtrip(self, engine: GradientCompressionEngine) -> None:
"""FP32 binary serialization achieves zero error."""
weights = [1.25, -3.5, 0.0, 42.125, -0.0078125]
buf = engine.encode_fp32(weights)
assert len(buf) == len(weights) * 4
recovered = engine.decode_fp32(buf)
assert len(recovered) == len(weights)
for orig, rec in zip(weights, recovered, strict=False):
assert math.isclose(orig, rec, rel_tol=1e-6)
def test_fp16_encode_decode_precision(self, engine: GradientCompressionEngine) -> None:
"""FP16 half-precision reduces size by 50% with low reconstruction error."""
weights = [0.1234, -0.5678, 1.9876, -3.1415, 0.0]
buf = engine.encode_fp16(weights)
assert len(buf) == len(weights) * 2 # exactly 2 bytes per float
recovered = engine.decode_fp16(buf)
assert len(recovered) == len(weights)
for orig, rec in zip(weights, recovered, strict=False):
assert abs(orig - rec) < 1e-3
def test_int8_symmetric_quantization_bounded_error(
self, engine: GradientCompressionEngine
) -> None:
"""INT8 symmetric affine quantization satisfies max error bound scale / 2."""
rng = np.random.default_rng(42)
weights = rng.uniform(-1.0, 1.0, size=200).tolist()
buf = engine.encode_int8(weights)
# 4 bytes scale header + 200 bytes quantized values
assert len(buf) == 4 + 200
recovered = engine.decode_int8(buf)
assert len(recovered) == 200
max_val = max(abs(w) for w in weights)
scale = max_val / 127.0
for orig, rec in zip(weights, recovered, strict=False):
assert abs(orig - rec) <= (scale / 2.0) + 1e-5
def test_quantization_empty_and_zero_inputs(self, engine: GradientCompressionEngine) -> None:
"""Handles empty buffers and zero vectors gracefully."""
assert engine.encode_fp32([]) == b""
assert engine.decode_fp32(b"") == []
assert engine.encode_fp16([]) == b""
assert engine.decode_fp16(b"") == []
assert engine.encode_int8([]) == b""
assert engine.decode_int8(b"") == []
zeros = [0.0] * 10
rec_int8 = engine.decode_int8(engine.encode_int8(zeros))
assert len(rec_int8) == 10
assert all(w == 0.0 for w in rec_int8)
class TestSparseCoordinateEncoding:
"""Validates Top-K Coordinate (COO) sparse binary format."""
@pytest.fixture
def engine(self) -> GradientCompressionEngine:
return GradientCompressionEngine()
def test_sparse_coo_encode_decode_roundtrip(self, engine: GradientCompressionEngine) -> None:
"""Top-K COO buffer decodes back to dense vector with non-zeros preserved."""
weights = [0.0] * 500
weights[10] = 5.25
weights[100] = -3.50
weights[250] = 8.125
weights[499] = -1.75
buf = engine.encode_sparse_coo(weights, k_percent=0.05)
# Header (10 bytes) + 4 indices (8 bytes) + 4 float16 values (8 bytes) = 26 bytes
assert len(buf) < 100
recovered = engine.decode_sparse_coo(buf)
assert len(recovered) == 500
assert math.isclose(recovered[10], 5.25, rel_tol=1e-3)
assert math.isclose(recovered[100], -3.50, rel_tol=1e-3)
assert math.isclose(recovered[250], 8.125, rel_tol=1e-3)
assert math.isclose(recovered[499], -1.75, rel_tol=1e-3)
# Other elements are zero
assert sum(1 for w in recovered if w != 0.0) == 4
def test_sparse_coo_empty_and_invalid_buffers(self, engine: GradientCompressionEngine) -> None:
"""Corrupted or undersized COO buffers return safe empty lists."""
assert engine.encode_sparse_coo([]) == b""
assert engine.decode_sparse_coo(b"") == []
assert engine.decode_sparse_coo(b"CORRUPTED") == []
class TestSerializationDispatcherAndProfiling:
"""Validates full serialization dispatcher and wire size hierarchy."""
@pytest.fixture
def engine(self) -> GradientCompressionEngine:
return GradientCompressionEngine()
def test_all_serialization_formats_produce_valid_roundtrip(
self, engine: GradientCompressionEngine
) -> None:
"""All 7 SerializationFormat enums serialize and deserialize cleanly."""
rng = np.random.default_rng(42)
weights = rng.normal(0.0, 0.1, size=250).tolist()
for fmt in SerializationFormat:
wire = engine.serialize_payload(weights, payload_format=fmt, k_percent=0.20)
assert isinstance(wire, bytes)
assert len(wire) > 0
rec = engine.deserialize_payload(wire, payload_format=fmt)
assert len(rec) == 250
if fmt in (SerializationFormat.RAW_JSON, SerializationFormat.RAW_FP32, SerializationFormat.ZSTD_COMPRESSED):
for o, r in zip(weights, rec, strict=False):
assert math.isclose(o, r, abs_tol=1e-5)
def test_wire_size_hierarchy(self, engine: GradientCompressionEngine) -> None:
"""Verifies wire size relationships:
size(SPARSE_TOPK_ZSTD) < size(INT8) < size(FP16) < size(FP32) < size(RAW_JSON).
"""
rng = np.random.default_rng(42)
weights = rng.normal(0.0, 0.05, size=1969).tolist()
profiles = engine.compare_all_serialization_formats(weights, k_percent=0.20)
p_json = profiles[SerializationFormat.RAW_JSON.value]
p_fp32 = profiles[SerializationFormat.RAW_FP32.value]
p_fp16 = profiles[SerializationFormat.QUANTIZED_FP16.value]
p_int8 = profiles[SerializationFormat.QUANTIZED_INT8.value]
p_sparse_zstd = profiles[SerializationFormat.SPARSE_TOPK_ZSTD.value]
assert p_json.wire_bytes > p_fp32.wire_bytes
assert p_fp32.wire_bytes > p_fp16.wire_bytes
assert p_fp16.wire_bytes > p_int8.wire_bytes
assert p_int8.wire_bytes > p_sparse_zstd.wire_bytes
# FP32 on 1,969 parameters is exactly 7,876 bytes
assert p_fp32.wire_bytes == 1969 * 4
# FP16 is exactly half of FP32
assert p_fp16.wire_bytes == 1969 * 2
# INT8 is 1 byte per param + 4 bytes header
assert p_int8.wire_bytes == 1969 + 4
def test_profile_serialized_payload_metrics(self, engine: GradientCompressionEngine) -> None:
"""SerializedPayloadProfile fields are consistent and bounded."""
weights = [float(i) for i in range(100)]
profile = engine.profile_serialized_payload(
weights, payload_format=SerializationFormat.QUANTIZED_FP16
)
assert profile.num_parameters == 100
assert profile.raw_uncompressed_bytes == 400
assert profile.wire_bytes == 200
assert profile.compression_ratio_vs_fp32 == 0.50
assert profile.bandwidth_savings_pct == 50.0
assert profile.serialization_time_us >= 0.0
assert profile.deserialization_time_us >= 0.0
assert profile.reconstruction_mae >= 0.0
class TestFederatedCommunicationProfiler:
"""Validates multi-round communication accounting across federated aggregation protocols."""
@pytest.fixture
def profiler(self) -> FederatedCommunicationProfiler:
return FederatedCommunicationProfiler()
def test_fedavg_and_fedprox_identical_bandwidth(
self, profiler: FederatedCommunicationProfiler
) -> None:
"""FedProx has identical communication payload to FedAvg."""
p_avg = profiler.profile_protocol(
num_parameters=1969, num_clients=3, num_rounds=5, protocol=FederatedProtocol.FED_AVG
)
p_prox = profiler.profile_protocol(
num_parameters=1969, num_clients=3, num_rounds=5, protocol=FederatedProtocol.FED_PROX
)
assert p_avg.total_round_bytes == p_prox.total_round_bytes
assert p_avg.total_multi_round_bytes == p_prox.total_multi_round_bytes
assert p_avg.relative_overhead_vs_fedavg == 1.0
assert p_prox.relative_overhead_vs_fedavg == 1.0
# 1 server broadcast (7,876) + 3 client uploads (3 * 7,876) = 31,504 bytes per round
assert p_avg.total_round_bytes == 31504
assert p_avg.total_multi_round_bytes == 31504 * 5
def test_scaffold_exact_2x_overhead(self, profiler: FederatedCommunicationProfiler) -> None:
"""SCAFFOLD requires exactly 2x bandwidth due to control variates."""
p_avg = profiler.profile_protocol(
num_parameters=1969, num_clients=3, num_rounds=5, protocol=FederatedProtocol.FED_AVG
)
p_scaffold = profiler.profile_protocol(
num_parameters=1969, num_clients=3, num_rounds=5, protocol=FederatedProtocol.SCAFFOLD
)
assert p_scaffold.total_round_bytes == 2 * p_avg.total_round_bytes
assert p_scaffold.total_multi_round_bytes == 2 * p_avg.total_multi_round_bytes
assert p_scaffold.relative_overhead_vs_fedavg == 2.0
def test_curve25519_secagg_overhead_bounds(
self, profiler: FederatedCommunicationProfiler
) -> None:
"""Curve25519 SecAgg adds minor cryptographic coordination overhead (4% - 10%)."""
p_secagg = profiler.profile_protocol(
num_parameters=1969,
num_clients=3,
num_rounds=5,
protocol=FederatedProtocol.CURVE25519_SECAGG,
)
assert p_secagg.relative_overhead_vs_fedavg > 1.0
assert p_secagg.relative_overhead_vs_fedavg < 1.15
assert p_secagg.total_multi_round_bytes > 157520
def test_tenseal_ckks_expansion_bounds(
self, profiler: FederatedCommunicationProfiler
) -> None:
"""TenSEAL CKKS exhibits polynomial ciphertext expansion (~10.5x)."""
p_ckks = profiler.profile_protocol(
num_parameters=1969,
num_clients=3,
num_rounds=5,
protocol=FederatedProtocol.TENSEAL_CKKS,
)
assert p_ckks.relative_overhead_vs_fedavg == 10.5
assert p_ckks.total_multi_round_mb > 1.0
def test_run_full_bandwidth_benchmark_suite(
self, profiler: FederatedCommunicationProfiler
) -> None:
"""Executes complete suite across all 7 formats and 5 protocols."""
suite = profiler.run_full_bandwidth_benchmark(
num_clients=3, num_rounds=5, model_name="CanonicalFraudMLP"
)
assert isinstance(suite, BandwidthBenchmarkSuiteResult)
assert suite.num_parameters == 1969
assert len(suite.serialization_profiles) == len(SerializationFormat)
assert len(suite.protocol_profiles) == len(FederatedProtocol)
assert "RAW_FP32" in suite.serialization_profiles
assert "FED_AVG" in suite.protocol_profiles
class TestFederatedEngineIntegration:
"""Validates live FederatedLearningEngine communication telemetry integration."""
def test_fl_engine_profile_communication_round(self) -> None:
"""FederatedLearningEngine delegates communication profiling to profiler."""
mock_settings = MagicMock()
mock_model_svc = MagicMock()
mock_privacy_svc = MagicMock()
engine = FederatedLearningEngine(
settings=mock_settings,
model_service=mock_model_svc,
privacy_service=mock_privacy_svc,
)
# Profile FedAvg round
profile_avg = engine.profile_communication_round(
num_parameters=1969,
num_clients=3,
method=AggregationMethod.FED_AVG,
serialization_format=SerializationFormat.RAW_FP32,
)
assert isinstance(profile_avg, ProtocolCommunicationProfile)
assert profile_avg.protocol == FederatedProtocol.FED_AVG
assert profile_avg.total_round_bytes == 31504
# Profile SCAFFOLD round
profile_scaffold = engine.profile_communication_round(
num_parameters=1969,
num_clients=3,
method=AggregationMethod.SCAFFOLD,
serialization_format=SerializationFormat.RAW_FP32,
)
assert profile_scaffold.protocol == FederatedProtocol.SCAFFOLD
assert profile_scaffold.total_round_bytes == 63008
|